mirror of
https://github.com/directus/directus.git
synced 2026-04-25 03:00:53 -04:00
Merge branch 'main' into api-reference
This commit is contained in:
3
.github/actions/build-images/action.yml
vendored
3
.github/actions/build-images/action.yml
vendored
@@ -9,8 +9,7 @@ inputs:
|
||||
required: true
|
||||
registry:
|
||||
description: "Registry"
|
||||
required: false
|
||||
default: ghcr.io
|
||||
required: true
|
||||
username:
|
||||
description: "Registry user"
|
||||
required: true
|
||||
|
||||
@@ -32,7 +32,7 @@ LABEL directus.version="${VERSION}"
|
||||
LABEL org.opencontainers.image.source https://github.com/${REPOSITORY}
|
||||
|
||||
ENV \
|
||||
PORT="41201" \
|
||||
PORT="8055" \
|
||||
PUBLIC_URL="/" \
|
||||
DB_CLIENT="sqlite3" \
|
||||
DB_FILENAME="/directus/database/database.sqlite" \
|
||||
@@ -85,7 +85,7 @@ RUN \
|
||||
mkdir -p database && \
|
||||
mkdir -p uploads
|
||||
|
||||
EXPOSE 41201
|
||||
EXPOSE 8055
|
||||
VOLUME \
|
||||
/directus/database \
|
||||
/directus/extensions \
|
||||
|
||||
@@ -80,7 +80,7 @@ WARN
|
||||
should_seed=false
|
||||
|
||||
set +e
|
||||
npx directus database install 2>&1 /dev/null
|
||||
npx directus database install &>/dev/null
|
||||
if [ "$?" == "0" ] ; then
|
||||
print --level=info "Database installed"
|
||||
should_seed=true
|
||||
|
||||
@@ -66,7 +66,7 @@ function main() {
|
||||
push=$(argument push "false")
|
||||
latest=$(argument latest "false")
|
||||
|
||||
registry=$(argument registry "ghcr.io")
|
||||
registry=$(argument registry "")
|
||||
registry=$(echo "${registry}" | tr '[:upper:]' '[:lower:]')
|
||||
|
||||
repository=$(argument repository "directus/next")
|
||||
@@ -75,6 +75,11 @@ function main() {
|
||||
version=$(argument version "")
|
||||
context=$(argument context ".")
|
||||
|
||||
image="${repository}"
|
||||
if [ "${registry}" != "" ]; then
|
||||
image="${registry}/${image}"
|
||||
fi
|
||||
|
||||
# Normalize tag
|
||||
if [ "${version}" == "" ]; then
|
||||
version=${GITHUB_REF##*/}
|
||||
@@ -102,7 +107,7 @@ function main() {
|
||||
# Push latest
|
||||
# TODO: check if it's really the latest
|
||||
if [ "${latest}" == "true" ]; then
|
||||
fqin="${registry}/${repository}:latest"
|
||||
fqin="${image}:latest"
|
||||
echo "Tagging ${fqin}"
|
||||
docker tag directus:main ${fqin}
|
||||
if [ "${push}" == "true" ]; then
|
||||
@@ -115,12 +120,12 @@ function main() {
|
||||
for tag in $tags
|
||||
do
|
||||
tag=$(echo "${tag}" | tr '[:upper:]' '[:lower:]')
|
||||
fqin="${registry}/${repository}:latest"
|
||||
fqin="${image}:${tag}"
|
||||
echo "Tagging ${fqin}"
|
||||
docker tag directus:main "${registry}/${repository}:${tag}"
|
||||
docker tag directus:main "${fqin}"
|
||||
if [ "${push}" == "true" ]; then
|
||||
echo "Pushing tag ${fqin}"
|
||||
docker push "${registry}/${repository}:${tag}"
|
||||
docker push "${fqin}"
|
||||
fi
|
||||
done
|
||||
|
||||
|
||||
12
.github/workflows/build-images.yml
vendored
12
.github/workflows/build-images.yml
vendored
@@ -11,7 +11,7 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Build
|
||||
- name: Build GitHub Container Registry
|
||||
uses: ./.github/actions/build-images
|
||||
with:
|
||||
registry: "ghcr.io"
|
||||
@@ -20,3 +20,13 @@ jobs:
|
||||
password: "${{ secrets.REGISTRY_PASSWORD }}"
|
||||
version: "${{ github.ref }}"
|
||||
push: "true"
|
||||
|
||||
- name: Build Docker Hub
|
||||
uses: ./.github/actions/build-images
|
||||
with:
|
||||
registry: "docker.io"
|
||||
repository: "${{ github.repository }}"
|
||||
username: "${{ secrets.DOCKERHUB_USERNAME }}"
|
||||
password: "${{ secrets.DOCKERHUB_PASSWORD }}"
|
||||
version: "${{ github.ref }}"
|
||||
push: "true"
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -7,3 +7,7 @@ npm-debug.log
|
||||
lerna-debug.log
|
||||
.nova
|
||||
*.code-workspace
|
||||
api/package-lock.json
|
||||
app/package-lock.json
|
||||
packages/**/package-lock.json
|
||||
|
||||
|
||||
5
api/cli.js
Executable file
5
api/cli.js
Executable file
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
try {
|
||||
return require('./dist/cli/index.js');
|
||||
} catch {}
|
||||
@@ -1,8 +1,8 @@
|
||||
####################################################################################################
|
||||
# General
|
||||
|
||||
PORT=41201
|
||||
PUBLIC_URL="http://localhost:41201"
|
||||
PORT=8055
|
||||
PUBLIC_URL="http://localhost:8055"
|
||||
LOG_LEVEL="info"
|
||||
LOG_STYLE="pretty"
|
||||
|
||||
@@ -70,7 +70,7 @@ CACHE_STORE=memory # memory | redis | memcache
|
||||
|
||||
STORAGE_LOCATIONS="local" # CSV of names
|
||||
|
||||
STORAGE_LOCAL_PUBLIC_URL="http://localhost:41201/uploads"
|
||||
STORAGE_LOCAL_PUBLIC_URL="http://localhost:8055/uploads"
|
||||
STORAGE_LOCAL_DRIVER="local"
|
||||
STORAGE_LOCAL_ROOT="./uploads"
|
||||
|
||||
|
||||
32834
api/package-lock.json
generated
32834
api/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "directus",
|
||||
"version": "9.0.0-beta.2",
|
||||
"version": "9.0.0-beta.8",
|
||||
"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.",
|
||||
@@ -48,11 +48,11 @@
|
||||
],
|
||||
"main": "dist/app.js",
|
||||
"bin": {
|
||||
"directus": "dist/cli/index.js"
|
||||
"directus": "cli.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",
|
||||
"build": "rm -rf dist && tsc --build && 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"
|
||||
@@ -64,7 +64,7 @@
|
||||
"example.env"
|
||||
],
|
||||
"dependencies": {
|
||||
"@directus/app": "^9.0.0-beta.2",
|
||||
"@directus/app": "file:../app",
|
||||
"@directus/format-title": "^3.2.0",
|
||||
"@directus/specs": "^9.0.0-beta.8",
|
||||
"@slynova/flydrive": "^1.0.2",
|
||||
@@ -87,10 +87,12 @@
|
||||
"exif-reader": "^1.0.3",
|
||||
"express": "^4.17.1",
|
||||
"express-async-handler": "^1.1.4",
|
||||
"express-graphql": "^0.11.0",
|
||||
"express-pino-logger": "^5.0.0",
|
||||
"express-session": "^1.17.1",
|
||||
"fs-extra": "^9.0.1",
|
||||
"grant": "^5.3.0",
|
||||
"graphql": "^15.3.0",
|
||||
"icc": "^2.0.0",
|
||||
"inquirer": "^7.3.3",
|
||||
"joi": "^17.1.1",
|
||||
@@ -99,7 +101,7 @@
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
"keyv": "^4.0.1",
|
||||
"knex": "^0.21.4",
|
||||
"knex-schema-inspector": "0.0.19",
|
||||
"knex-schema-inspector": "0.0.20",
|
||||
"liquidjs": "^9.14.1",
|
||||
"lodash": "^4.17.19",
|
||||
"macos-release": "^2.4.1",
|
||||
@@ -128,51 +130,11 @@
|
||||
"pg": "^8.3.3",
|
||||
"sqlite3": "^5.0.0"
|
||||
},
|
||||
"gitHead": "4476da28dbbc2824e680137aa28b2b91b5afabec",
|
||||
"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/json2csv": "^5.0.1",
|
||||
"@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",
|
||||
"copyfiles": "^2.4.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": "4476da28dbbc2824e680137aa28b2b91b5afabec"
|
||||
"ts-node-dev": "^1.0.0-pre.63",
|
||||
"typescript": "^4.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import { track } from './utils/track';
|
||||
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';
|
||||
@@ -34,6 +33,7 @@ import settingsRouter from './controllers/settings';
|
||||
import usersRouter from './controllers/users';
|
||||
import utilsRouter from './controllers/utils';
|
||||
import webhooksRouter from './controllers/webhooks';
|
||||
import graphqlRouter from './controllers/graphql';
|
||||
|
||||
import notFoundHandler from './controllers/not-found';
|
||||
import sanitizeQuery from './middleware/sanitize-query';
|
||||
@@ -93,29 +93,31 @@ if (env.RATE_LIMITER_ENABLED === true) {
|
||||
|
||||
app.use(sanitizeQuery);
|
||||
|
||||
app.use('/auth', authRouter, respond);
|
||||
app.use('/auth', authRouter);
|
||||
|
||||
app.use(authenticate);
|
||||
app.use(cache);
|
||||
|
||||
app.use('/activity', activityRouter, respond);
|
||||
app.use('/assets', assetsRouter, respond);
|
||||
app.use('/collections', collectionsRouter, respond);
|
||||
app.use('/extensions', extensionsRouter, respond);
|
||||
app.use('/fields', fieldsRouter, respond);
|
||||
app.use('/files', filesRouter, respond);
|
||||
app.use('/folders', foldersRouter, respond);
|
||||
app.use('/items', itemsRouter, respond);
|
||||
app.use('/permissions', permissionsRouter, respond);
|
||||
app.use('/presets', presetsRouter, respond);
|
||||
app.use('/relations', relationsRouter, respond);
|
||||
app.use('/revisions', revisionsRouter, respond);
|
||||
app.use('/roles', rolesRouter, respond);
|
||||
app.use('/server/', serverRouter, respond);
|
||||
app.use('/settings', settingsRouter, respond);
|
||||
app.use('/users', usersRouter, respond);
|
||||
app.use('/utils', utilsRouter, respond);
|
||||
app.use('/webhooks', webhooksRouter, respond);
|
||||
app.use('/graphql', graphqlRouter);
|
||||
|
||||
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('/custom', customRouter);
|
||||
app.use(notFoundHandler);
|
||||
app.use(errorHandler);
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import knex from 'knex';
|
||||
import logger from '../../logger';
|
||||
import { Express } from 'express';
|
||||
|
||||
export default async function start() {
|
||||
const { default: env } = require('../../env');
|
||||
const { validateDBConnection } = require('../../database');
|
||||
const database = require('../../database');
|
||||
const connection = database.default as knex;
|
||||
|
||||
await validateDBConnection();
|
||||
await database.validateDBConnection();
|
||||
|
||||
const app: Express = require('../../app').default;
|
||||
|
||||
@@ -20,10 +22,25 @@ export default async function start() {
|
||||
process.on(signal, () =>
|
||||
server.close((err) => {
|
||||
if (err) {
|
||||
logger.error(err.message, { err });
|
||||
return;
|
||||
logger.error(`Failed to close server: ${err.message}`, {
|
||||
err,
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
||||
logger.info('Server stopped.');
|
||||
|
||||
connection
|
||||
.destroy()
|
||||
.then(() => {
|
||||
logger.info('Database connection stopped.');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.info(`Failed to destroy database connections: ${err.message}`, {
|
||||
err,
|
||||
});
|
||||
process.exit(1);
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
####################################################################################################
|
||||
## General
|
||||
|
||||
PORT=41201
|
||||
PORT=8055
|
||||
PUBLIC_URL="/"
|
||||
|
||||
####################################################################################################
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ActivityService, MetaService } from '../services';
|
||||
import { Action } from '../types';
|
||||
import { ForbiddenException } from '../exceptions';
|
||||
import useCollection from '../middleware/use-collection';
|
||||
import { respond } from '../middleware/respond';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -24,7 +25,8 @@ router.get(
|
||||
};
|
||||
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
router.get(
|
||||
@@ -38,7 +40,8 @@ router.get(
|
||||
};
|
||||
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
router.post(
|
||||
@@ -49,7 +52,7 @@ router.post(
|
||||
const primaryKey = await service.create({
|
||||
...req.body,
|
||||
action: Action.COMMENT,
|
||||
action_by: req.accountability?.user,
|
||||
user: req.accountability?.user,
|
||||
ip: req.ip,
|
||||
user_agent: req.get('user-agent'),
|
||||
});
|
||||
@@ -69,7 +72,8 @@ router.post(
|
||||
}
|
||||
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
router.patch(
|
||||
@@ -93,7 +97,8 @@ router.patch(
|
||||
}
|
||||
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
router.delete(
|
||||
@@ -103,7 +108,8 @@ router.delete(
|
||||
await service.delete(req.params.pk);
|
||||
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -9,10 +9,11 @@ import { Transformation } from '../types/assets';
|
||||
import storage from '../storage';
|
||||
import { PayloadService, AssetsService } from '../services';
|
||||
import useCollection from '../middleware/use-collection';
|
||||
import { respond } from '../middleware/respond';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(useCollection('directus_Files'));
|
||||
router.use(useCollection('directus_files'));
|
||||
|
||||
router.get(
|
||||
'/:pk',
|
||||
|
||||
@@ -11,6 +11,7 @@ import env from '../env';
|
||||
import { UsersService, AuthenticationService } from '../services';
|
||||
import grantConfig from '../grant';
|
||||
import { RouteNotFoundException } from '../exceptions';
|
||||
import { respond } from '../middleware/respond';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -74,7 +75,8 @@ router.post(
|
||||
|
||||
res.locals.payload = payload;
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
router.post(
|
||||
@@ -125,7 +127,8 @@ router.post(
|
||||
|
||||
res.locals.payload = payload;
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
router.post(
|
||||
@@ -152,7 +155,8 @@ router.post(
|
||||
|
||||
await authenticationService.logout(currentRefreshToken);
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
router.post(
|
||||
@@ -178,7 +182,8 @@ router.post(
|
||||
} finally {
|
||||
return next();
|
||||
}
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
router.post(
|
||||
@@ -201,36 +206,45 @@ router.post(
|
||||
const service = new UsersService({ accountability });
|
||||
await service.resetPassword(req.body.token, req.body.password);
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
router.get('/oauth', asyncHandler(async (req, res, next) => {
|
||||
const providers = env.OAUTH_PROVIDERS.split(',');
|
||||
res.locals.payload = { data: providers };
|
||||
return next();
|
||||
}));
|
||||
router.get(
|
||||
'/oauth',
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const providers = env.OAUTH_PROVIDERS.split(',').filter((p: string) => p);
|
||||
res.locals.payload = { data: providers.length > 0 ? providers : null };
|
||||
return next();
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
router.use(
|
||||
'/oauth',
|
||||
session({ secret: env.SECRET as string, saveUninitialized: false, resave: false })
|
||||
);
|
||||
|
||||
router.get('/oauth/:provider', asyncHandler(async(req, res, next) => {
|
||||
const config = { ...grantConfig };
|
||||
delete config.defaults;
|
||||
router.get(
|
||||
'/oauth/:provider',
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const config = { ...grantConfig };
|
||||
delete config.defaults;
|
||||
|
||||
const availableProviders = Object.keys(config);
|
||||
const availableProviders = Object.keys(config);
|
||||
|
||||
if (availableProviders.includes(req.params.provider) === false) {
|
||||
throw new RouteNotFoundException(`/auth/oauth/${req.params.provider}`);
|
||||
}
|
||||
if (availableProviders.includes(req.params.provider) === false) {
|
||||
throw new RouteNotFoundException(`/auth/oauth/${req.params.provider}`);
|
||||
}
|
||||
|
||||
if (req.query?.redirect && req.session) {
|
||||
req.session.redirect = req.query.redirect;
|
||||
}
|
||||
if (req.query?.redirect && req.session) {
|
||||
req.session.redirect = req.query.redirect;
|
||||
}
|
||||
|
||||
next();
|
||||
}));
|
||||
next();
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
router.use(grant.express()(grantConfig));
|
||||
|
||||
@@ -249,11 +263,16 @@ router.get(
|
||||
accountability: accountability,
|
||||
});
|
||||
|
||||
const email = getEmailFromProfile(req.params.provider, req.session!.grant.response?.profile);
|
||||
const email = getEmailFromProfile(
|
||||
req.params.provider,
|
||||
req.session!.grant.response?.profile
|
||||
);
|
||||
|
||||
req.session?.destroy(() => { });
|
||||
req.session?.destroy(() => {});
|
||||
|
||||
const { accessToken, refreshToken, expires } = await authenticationService.authenticate({ email });
|
||||
const { accessToken, refreshToken, expires } = await authenticationService.authenticate({
|
||||
email,
|
||||
});
|
||||
|
||||
if (redirect) {
|
||||
res.cookie('directus_refresh_token', refreshToken, {
|
||||
@@ -272,7 +291,8 @@ router.get(
|
||||
|
||||
return next();
|
||||
}
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Router } from 'express';
|
||||
import asyncHandler from 'express-async-handler';
|
||||
import { CollectionsService, MetaService } from '../services';
|
||||
import { ForbiddenException } from '../exceptions';
|
||||
import { respond } from '../middleware/respond';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -15,7 +16,8 @@ router.post(
|
||||
|
||||
res.locals.payload = { data: record || null };
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
router.get(
|
||||
@@ -29,7 +31,8 @@ router.get(
|
||||
|
||||
res.locals.payload = { data: collections || null, meta };
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
router.get(
|
||||
@@ -52,7 +55,8 @@ router.get(
|
||||
}
|
||||
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
router.patch(
|
||||
@@ -76,7 +80,8 @@ router.patch(
|
||||
}
|
||||
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
router.delete(
|
||||
@@ -89,7 +94,8 @@ router.delete(
|
||||
await collectionsService.delete(collectionKey as any);
|
||||
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -3,6 +3,7 @@ import asyncHandler from 'express-async-handler';
|
||||
import { RouteNotFoundException } from '../exceptions';
|
||||
import { listExtensions } from '../extensions';
|
||||
import env from '../env';
|
||||
import { respond } from '../middleware/respond';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -25,7 +26,8 @@ router.get(
|
||||
};
|
||||
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { InvalidPayloadException, ForbiddenException } from '../exceptions';
|
||||
import Joi from 'joi';
|
||||
import { types, Field } from '../types';
|
||||
import useCollection from '../middleware/use-collection';
|
||||
import { respond } from '../middleware/respond';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -20,7 +21,8 @@ router.get(
|
||||
|
||||
res.locals.payload = { data: fields || null };
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
router.get(
|
||||
@@ -32,7 +34,8 @@ router.get(
|
||||
|
||||
res.locals.payload = { data: fields || null };
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
router.get(
|
||||
@@ -48,7 +51,8 @@ router.get(
|
||||
|
||||
res.locals.payload = { data: field || null };
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
const newFieldSchema = Joi.object({
|
||||
@@ -96,7 +100,8 @@ router.post(
|
||||
}
|
||||
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
router.patch(
|
||||
@@ -129,7 +134,8 @@ router.patch(
|
||||
}
|
||||
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
router.patch(
|
||||
@@ -156,7 +162,8 @@ router.patch(
|
||||
}
|
||||
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
router.delete(
|
||||
@@ -166,7 +173,8 @@ router.delete(
|
||||
const service = new FieldsService({ accountability: req.accountability });
|
||||
await service.deleteField(req.params.collection, req.params.field);
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -11,6 +11,7 @@ import { InvalidPayloadException, ForbiddenException } from '../exceptions';
|
||||
import url from 'url';
|
||||
import path from 'path';
|
||||
import useCollection from '../middleware/use-collection';
|
||||
import { respond } from '../middleware/respond';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -112,8 +113,9 @@ router.post(
|
||||
|
||||
try {
|
||||
const record = await service.readByKey(keys as any, req.sanitizedQuery);
|
||||
|
||||
res.locals.payload = {
|
||||
data: res.locals.savedFiles.length === 1 ? record![0] : record || null,
|
||||
data: record,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof ForbiddenException) {
|
||||
@@ -124,7 +126,8 @@ router.post(
|
||||
}
|
||||
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
const importSchema = Joi.object({
|
||||
@@ -172,7 +175,8 @@ router.post(
|
||||
}
|
||||
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
router.get(
|
||||
@@ -186,7 +190,8 @@ router.get(
|
||||
|
||||
res.locals.payload = { data: records || null, meta };
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
router.get(
|
||||
@@ -197,7 +202,8 @@ router.get(
|
||||
const record = await service.readByKey(keys as any, req.sanitizedQuery);
|
||||
res.locals.payload = { data: record || null };
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
router.patch(
|
||||
@@ -226,7 +232,8 @@ router.patch(
|
||||
}
|
||||
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
router.delete(
|
||||
@@ -236,7 +243,8 @@ router.delete(
|
||||
const service = new FilesService({ accountability: req.accountability });
|
||||
await service.delete(keys as any);
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -3,6 +3,7 @@ import asyncHandler from 'express-async-handler';
|
||||
import { FoldersService, MetaService } from '../services';
|
||||
import { ForbiddenException } from '../exceptions';
|
||||
import useCollection from '../middleware/use-collection';
|
||||
import { respond } from '../middleware/respond';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -26,7 +27,8 @@ router.post(
|
||||
}
|
||||
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
router.get(
|
||||
@@ -40,7 +42,8 @@ router.get(
|
||||
|
||||
res.locals.payload = { data: records || null, meta };
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
router.get(
|
||||
@@ -52,7 +55,8 @@ router.get(
|
||||
|
||||
res.locals.payload = { data: record || null };
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
router.patch(
|
||||
@@ -74,7 +78,8 @@ router.patch(
|
||||
}
|
||||
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
router.delete(
|
||||
@@ -84,7 +89,8 @@ router.delete(
|
||||
const primaryKey = req.params.pk.includes(',') ? req.params.pk.split(',') : req.params.pk;
|
||||
await service.delete(primaryKey as any);
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
16
api/src/controllers/graphql.ts
Normal file
16
api/src/controllers/graphql.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Router } from 'express';
|
||||
import { graphqlHTTP } from 'express-graphql';
|
||||
import { GraphQLService } from '../services';
|
||||
import asyncHandler from 'express-async-handler';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(asyncHandler(async (req, res) => {
|
||||
const service = new GraphQLService({ accountability: req.accountability });
|
||||
const schema = await service.getSchema();
|
||||
|
||||
graphqlHTTP({ schema, graphiql: true })(req, res);
|
||||
}));
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -3,6 +3,7 @@ import asyncHandler from 'express-async-handler';
|
||||
import collectionExists from '../middleware/collection-exists';
|
||||
import { ItemsService, MetaService } from '../services';
|
||||
import { RouteNotFoundException, ForbiddenException } from '../exceptions';
|
||||
import { respond } from '../middleware/respond';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -29,7 +30,8 @@ router.post(
|
||||
}
|
||||
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
router.get(
|
||||
@@ -50,7 +52,8 @@ router.get(
|
||||
data: records || null,
|
||||
};
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
router.get(
|
||||
@@ -69,7 +72,8 @@ router.get(
|
||||
data: result || null,
|
||||
};
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
router.patch(
|
||||
@@ -100,7 +104,8 @@ router.patch(
|
||||
}
|
||||
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
router.patch(
|
||||
@@ -128,7 +133,8 @@ router.patch(
|
||||
}
|
||||
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
router.delete(
|
||||
@@ -139,7 +145,8 @@ router.delete(
|
||||
const pk = req.params.pk.includes(',') ? req.params.pk.split(',') : req.params.pk;
|
||||
await service.delete(pk as any);
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { PermissionsService, MetaService } from '../services';
|
||||
import { clone } from 'lodash';
|
||||
import { InvalidCredentialsException, ForbiddenException } from '../exceptions';
|
||||
import useCollection from '../middleware/use-collection';
|
||||
import { respond } from '../middleware/respond';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -26,7 +27,8 @@ router.post(
|
||||
throw error;
|
||||
}
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
router.get(
|
||||
@@ -40,7 +42,8 @@ router.get(
|
||||
|
||||
res.locals.payload = { data: item || null, meta };
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
router.get(
|
||||
@@ -64,7 +67,8 @@ router.get(
|
||||
|
||||
res.locals.payload = { data: items || null };
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
router.get(
|
||||
@@ -77,7 +81,8 @@ router.get(
|
||||
|
||||
res.locals.payload = { data: record || null };
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
router.patch(
|
||||
@@ -99,7 +104,8 @@ router.patch(
|
||||
}
|
||||
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
router.delete(
|
||||
@@ -109,7 +115,8 @@ router.delete(
|
||||
const pk = req.params.pk.includes(',') ? req.params.pk.split(',') : req.params.pk;
|
||||
await service.delete(pk as any);
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -3,6 +3,7 @@ import asyncHandler from 'express-async-handler';
|
||||
import { PresetsService, MetaService } from '../services';
|
||||
import { ForbiddenException } from '../exceptions';
|
||||
import useCollection from '../middleware/use-collection';
|
||||
import { respond } from '../middleware/respond';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -26,7 +27,8 @@ router.post(
|
||||
}
|
||||
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
router.get(
|
||||
@@ -40,7 +42,8 @@ router.get(
|
||||
|
||||
res.locals.payload = { data: records || null, meta };
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
router.get(
|
||||
@@ -52,7 +55,8 @@ router.get(
|
||||
|
||||
res.locals.payload = { data: record || null };
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
router.patch(
|
||||
@@ -74,7 +78,8 @@ router.patch(
|
||||
}
|
||||
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
router.delete(
|
||||
@@ -84,7 +89,8 @@ router.delete(
|
||||
const pk = req.params.pk.includes(',') ? req.params.pk.split(',') : req.params.pk;
|
||||
await service.delete(pk as any);
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -3,6 +3,7 @@ import asyncHandler from 'express-async-handler';
|
||||
import { RelationsService, MetaService } from '../services';
|
||||
import { ForbiddenException } from '../exceptions';
|
||||
import useCollection from '../middleware/use-collection';
|
||||
import { respond } from '../middleware/respond';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -26,7 +27,8 @@ router.post(
|
||||
}
|
||||
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
router.get(
|
||||
@@ -40,7 +42,8 @@ router.get(
|
||||
|
||||
res.locals.payload = { data: records || null, meta };
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
router.get(
|
||||
@@ -51,7 +54,8 @@ router.get(
|
||||
const record = await service.readByKey(pk as any, req.sanitizedQuery);
|
||||
res.locals.payload = { data: record || null };
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
router.patch(
|
||||
@@ -73,7 +77,8 @@ router.patch(
|
||||
}
|
||||
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
router.delete(
|
||||
@@ -83,7 +88,8 @@ router.delete(
|
||||
const pk = req.params.pk.includes(',') ? req.params.pk.split(',') : req.params.pk;
|
||||
await service.delete(pk as any);
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -2,6 +2,7 @@ import express from 'express';
|
||||
import asyncHandler from 'express-async-handler';
|
||||
import { RevisionsService, MetaService } from '../services';
|
||||
import useCollection from '../middleware/use-collection';
|
||||
import { respond } from '../middleware/respond';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -18,7 +19,8 @@ router.get(
|
||||
|
||||
res.locals.payload = { data: records || null, meta };
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
router.get(
|
||||
@@ -29,7 +31,8 @@ router.get(
|
||||
const record = await service.readByKey(pk as any, req.sanitizedQuery);
|
||||
res.locals.payload = { data: record || null };
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -3,6 +3,7 @@ import asyncHandler from 'express-async-handler';
|
||||
import { RolesService, MetaService } from '../services';
|
||||
import { ForbiddenException } from '../exceptions';
|
||||
import useCollection from '../middleware/use-collection';
|
||||
import { respond } from '../middleware/respond';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -26,7 +27,8 @@ router.post(
|
||||
}
|
||||
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
router.get(
|
||||
@@ -40,7 +42,8 @@ router.get(
|
||||
|
||||
res.locals.payload = { data: records || null, meta };
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
router.get(
|
||||
@@ -51,7 +54,8 @@ router.get(
|
||||
const record = await service.readByKey(pk as any, req.sanitizedQuery);
|
||||
res.locals.payload = { data: record || null };
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
router.patch(
|
||||
@@ -73,7 +77,8 @@ router.patch(
|
||||
}
|
||||
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
router.delete(
|
||||
@@ -83,7 +88,8 @@ router.delete(
|
||||
const pk = req.params.pk.includes(',') ? req.params.pk.split(',') : req.params.pk;
|
||||
await service.delete(pk as any);
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Router } from 'express';
|
||||
import { ServerService } from '../services';
|
||||
import { SpecificationService } from '../services'
|
||||
import asyncHandler from 'express-async-handler';
|
||||
import { respond } from '../middleware/respond';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -14,11 +15,15 @@ router.get('/specs/oas', asyncHandler(async (req, res, next) => {
|
||||
|
||||
router.get('/ping', (req, res) => res.send('pong'));
|
||||
|
||||
router.get('/info', (req, res, next) => {
|
||||
const service = new ServerService({ accountability: req.accountability });
|
||||
const data = service.serverInfo();
|
||||
res.locals.payload = data;
|
||||
return next();
|
||||
});
|
||||
router.get(
|
||||
'/info',
|
||||
(req, res, next) => {
|
||||
const service = new ServerService({ accountability: req.accountability });
|
||||
const data = service.serverInfo();
|
||||
res.locals.payload = data;
|
||||
return next();
|
||||
},
|
||||
respond
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -3,6 +3,7 @@ import asyncHandler from 'express-async-handler';
|
||||
import { SettingsService } from '../services';
|
||||
import { ForbiddenException } from '../exceptions';
|
||||
import useCollection from '../middleware/use-collection';
|
||||
import { respond } from '../middleware/respond';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -15,7 +16,8 @@ router.get(
|
||||
const records = await service.readSingleton(req.sanitizedQuery);
|
||||
res.locals.payload = { data: records || null };
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
router.patch(
|
||||
@@ -36,7 +38,8 @@ router.patch(
|
||||
}
|
||||
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import express from 'express';
|
||||
import argon2 from 'argon2';
|
||||
import asyncHandler from 'express-async-handler';
|
||||
import Joi from 'joi';
|
||||
import {
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
} from '../exceptions';
|
||||
import { UsersService, MetaService, AuthenticationService } from '../services';
|
||||
import useCollection from '../middleware/use-collection';
|
||||
import { respond } from '../middleware/respond';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -31,7 +33,8 @@ router.post(
|
||||
}
|
||||
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
router.get(
|
||||
@@ -45,7 +48,8 @@ router.get(
|
||||
|
||||
res.locals.payload = { data: item || null, meta };
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
router.get(
|
||||
@@ -60,7 +64,8 @@ router.get(
|
||||
|
||||
res.locals.payload = { data: item || null };
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
router.get(
|
||||
@@ -72,7 +77,8 @@ router.get(
|
||||
const items = await service.readByKey(pk as any, req.sanitizedQuery);
|
||||
res.locals.payload = { data: items || null };
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
router.patch(
|
||||
@@ -88,7 +94,8 @@ router.patch(
|
||||
|
||||
res.locals.payload = { data: item || null };
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
router.patch(
|
||||
@@ -106,7 +113,8 @@ router.patch(
|
||||
await service.update({ last_page: req.body.last_page }, req.accountability.user);
|
||||
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
router.patch(
|
||||
@@ -128,7 +136,8 @@ router.patch(
|
||||
}
|
||||
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
router.delete(
|
||||
@@ -139,7 +148,8 @@ router.delete(
|
||||
await service.delete(pk as any);
|
||||
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
const inviteSchema = Joi.object({
|
||||
@@ -156,7 +166,8 @@ router.post(
|
||||
const service = new UsersService({ accountability: req.accountability });
|
||||
await service.inviteUser(req.body.email, req.body.role);
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
const acceptInviteSchema = Joi.object({
|
||||
@@ -172,7 +183,8 @@ router.post(
|
||||
const service = new UsersService({ accountability: req.accountability });
|
||||
await service.acceptInvite(req.body.token, req.body.password);
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
router.post(
|
||||
@@ -182,12 +194,21 @@ router.post(
|
||||
throw new InvalidCredentialsException();
|
||||
}
|
||||
|
||||
if (!req.body.password) {
|
||||
throw new InvalidPayloadException(`"password" is required`);
|
||||
}
|
||||
|
||||
const service = new UsersService({ accountability: req.accountability });
|
||||
|
||||
const authService = new AuthenticationService({ accountability: req.accountability });
|
||||
await authService.verifyPassword(req.accountability.user, req.body.password);
|
||||
|
||||
const { url, secret } = await service.enableTFA(req.accountability.user);
|
||||
|
||||
res.locals.payload = { data: { secret, otpauth_url: url } };
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
router.post(
|
||||
@@ -212,7 +233,8 @@ router.post(
|
||||
|
||||
await service.disableTFA(req.accountability.user);
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -4,8 +4,9 @@ import { nanoid } from 'nanoid';
|
||||
import { InvalidQueryException, InvalidPayloadException } from '../exceptions';
|
||||
import argon2 from 'argon2';
|
||||
import collectionExists from '../middleware/collection-exists';
|
||||
import { UtilsService } from '../services';
|
||||
import { UtilsService, RevisionsService } from '../services';
|
||||
import Joi from 'joi';
|
||||
import { respond } from '../middleware/respond';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -18,7 +19,8 @@ router.get(
|
||||
const string = nanoid(req.query?.length ? Number(req.query.length) : 32);
|
||||
|
||||
return res.json({ data: string });
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
router.post(
|
||||
@@ -31,7 +33,8 @@ router.post(
|
||||
const hash = await argon2.hash(req.body.string);
|
||||
|
||||
return res.json({ data: hash });
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
router.post(
|
||||
@@ -48,7 +51,8 @@ router.post(
|
||||
const result = await argon2.verify(req.body.hash, req.body.string);
|
||||
|
||||
return res.json({ data: result });
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
const SortSchema = Joi.object({
|
||||
@@ -67,7 +71,18 @@ router.post(
|
||||
await service.sort(req.collection, req.body);
|
||||
|
||||
return res.status(200).end();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/revert/:revision',
|
||||
asyncHandler(async (req, res, next) => {
|
||||
const service = new RevisionsService({ accountability: req.accountability });
|
||||
await service.revert(req.params.revision);
|
||||
next();
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -3,6 +3,7 @@ import asyncHandler from 'express-async-handler';
|
||||
import { WebhooksService, MetaService } from '../services';
|
||||
import { ForbiddenException } from '../exceptions';
|
||||
import useCollection from '../middleware/use-collection';
|
||||
import { respond } from '../middleware/respond';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -26,7 +27,8 @@ router.post(
|
||||
}
|
||||
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
router.get(
|
||||
@@ -40,7 +42,8 @@ router.get(
|
||||
|
||||
res.locals.payload = { data: records || null, meta };
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
router.get(
|
||||
@@ -52,7 +55,8 @@ router.get(
|
||||
|
||||
res.locals.payload = { data: record || null };
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
router.patch(
|
||||
@@ -74,7 +78,8 @@ router.patch(
|
||||
}
|
||||
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
router.delete(
|
||||
@@ -85,7 +90,8 @@ router.delete(
|
||||
await service.delete(pk as any);
|
||||
|
||||
return next();
|
||||
})
|
||||
}),
|
||||
respond
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -215,6 +215,7 @@ function removeTemporaryFields(rawItem: Item | Item[], ast: AST | NestedCollecti
|
||||
const nestedCollections = ast.children.filter((child) => child.type === 'collection') as NestedCollectionAST[];
|
||||
|
||||
for (const rawItem of rawItems) {
|
||||
if (rawItem === null) return rawItem;
|
||||
const item = fields.includes('*') ? rawItem : pick(rawItem, fields);
|
||||
|
||||
for (const nestedCollection of nestedCollections) {
|
||||
|
||||
@@ -21,7 +21,7 @@ columns:
|
||||
type: boolean
|
||||
nullable: false
|
||||
default: false
|
||||
translation:
|
||||
translations:
|
||||
type: json
|
||||
archive_field:
|
||||
type: string
|
||||
|
||||
@@ -52,7 +52,7 @@ columns:
|
||||
references:
|
||||
table: directus_fields
|
||||
column: id
|
||||
translation:
|
||||
translations:
|
||||
type: json
|
||||
note:
|
||||
type: text
|
||||
|
||||
@@ -7,9 +7,9 @@ columns:
|
||||
type: string
|
||||
length: 45
|
||||
nullable: false
|
||||
action_by:
|
||||
user:
|
||||
type: uuid
|
||||
action_on:
|
||||
timestamp:
|
||||
type: timestamp
|
||||
nullable: false
|
||||
default: '$now'
|
||||
|
||||
@@ -9,7 +9,7 @@ columns:
|
||||
type: string
|
||||
length: 255
|
||||
nullable: false
|
||||
parent_folder:
|
||||
parent:
|
||||
type: uuid
|
||||
references:
|
||||
table: directus_folders
|
||||
|
||||
@@ -6,23 +6,30 @@ columns:
|
||||
name:
|
||||
type: string
|
||||
length: 255
|
||||
nullable: false
|
||||
method:
|
||||
type: string
|
||||
length: 10
|
||||
default: POST
|
||||
nullable: false
|
||||
url:
|
||||
type: string
|
||||
length: 255
|
||||
nullable: false
|
||||
status:
|
||||
type: string
|
||||
length: 10
|
||||
default: inactive
|
||||
default: active
|
||||
nullable: false
|
||||
data:
|
||||
type: boolean
|
||||
default: false
|
||||
default: true
|
||||
nullable: false
|
||||
actions:
|
||||
type: string
|
||||
length: 100
|
||||
nullable: false
|
||||
collections:
|
||||
type: string
|
||||
length: 255
|
||||
nullable: false
|
||||
|
||||
@@ -6,7 +6,7 @@ defaults:
|
||||
singleton: false
|
||||
icon: null
|
||||
note: null
|
||||
translation: null
|
||||
translations: null
|
||||
display_template: null
|
||||
|
||||
data:
|
||||
@@ -29,6 +29,7 @@ data:
|
||||
icon: supervised_user_circle
|
||||
- collection: directus_sessions
|
||||
- collection: directus_settings
|
||||
singleton: true
|
||||
- collection: directus_users
|
||||
archive_field: status
|
||||
archive_value: archived
|
||||
|
||||
@@ -38,16 +38,32 @@ data:
|
||||
layout: tabular
|
||||
layout_query:
|
||||
tabular:
|
||||
sort: -action_on
|
||||
sort: -timestamp
|
||||
fields:
|
||||
- action
|
||||
- collection
|
||||
- action_on
|
||||
- action_by
|
||||
- timestamp
|
||||
- user
|
||||
layout_options:
|
||||
tabular:
|
||||
widths:
|
||||
action: 100
|
||||
collection: 210
|
||||
action_on: 240
|
||||
action_by: 240
|
||||
timestamp: 240
|
||||
user: 240
|
||||
|
||||
- collection: directus_webhooks
|
||||
layout: tabular
|
||||
layout_query:
|
||||
tabular:
|
||||
fields:
|
||||
- status
|
||||
- name
|
||||
- method
|
||||
- url
|
||||
layout_options:
|
||||
tabular:
|
||||
widths:
|
||||
status: 36
|
||||
name: 300
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ data:
|
||||
one_collection: directus_users
|
||||
one_primary: id
|
||||
- many_collection: directus_folders
|
||||
many_field: parent_folder
|
||||
many_field: parent
|
||||
many_primary: id
|
||||
one_collection: directus_folders
|
||||
one_primary: id
|
||||
@@ -54,7 +54,7 @@ data:
|
||||
one_field: fields
|
||||
one_primary: id
|
||||
- many_collection: directus_activity
|
||||
many_field: action_by
|
||||
many_field: user
|
||||
many_primary: id
|
||||
one_collection: directus_users
|
||||
one_primary: id
|
||||
|
||||
@@ -63,7 +63,7 @@ fields:
|
||||
sort: 7
|
||||
width: half
|
||||
- collection: directus_collections
|
||||
field: translation
|
||||
field: translations
|
||||
special: json
|
||||
interface: repeater
|
||||
options:
|
||||
@@ -78,7 +78,7 @@ fields:
|
||||
interface: system-language
|
||||
width: half
|
||||
- field: translation
|
||||
name: Translation
|
||||
name: translation
|
||||
type: string
|
||||
meta:
|
||||
interface: text-input
|
||||
|
||||
@@ -33,7 +33,7 @@ fields:
|
||||
locked: true
|
||||
special: csv
|
||||
- collection: directus_fields
|
||||
field: translation
|
||||
field: translations
|
||||
hidden: true
|
||||
locked: true
|
||||
special: json
|
||||
|
||||
@@ -9,9 +9,9 @@ fields:
|
||||
iconRight: title
|
||||
placeholder: My project...
|
||||
sort: 1
|
||||
translation:
|
||||
translations:
|
||||
locale: en-US
|
||||
translation: Name
|
||||
translations: Name
|
||||
width: half
|
||||
- collection: directus_settings
|
||||
field: project_url
|
||||
@@ -21,9 +21,9 @@ fields:
|
||||
iconRight: link
|
||||
placeholder: https://example.com
|
||||
sort: 2
|
||||
translation:
|
||||
translations:
|
||||
locale: en-US
|
||||
translation: Website
|
||||
translations: Website
|
||||
width: half
|
||||
- collection: directus_settings
|
||||
field: project_color
|
||||
@@ -31,9 +31,9 @@ fields:
|
||||
locked: true
|
||||
note: Login & Logo Background
|
||||
sort: 3
|
||||
translation:
|
||||
translations:
|
||||
locale: en-US
|
||||
translation: Brand Color
|
||||
translations: Brand Color
|
||||
width: half
|
||||
- collection: directus_settings
|
||||
field: project_logo
|
||||
@@ -41,9 +41,9 @@ fields:
|
||||
locked: true
|
||||
note: White 40x40 SVG/PNG
|
||||
sort: 4
|
||||
translation:
|
||||
translations:
|
||||
locale: en-US
|
||||
translation: Brand Logo
|
||||
translations: Brand Logo
|
||||
width: half
|
||||
- collection: directus_settings
|
||||
field: public_divider
|
||||
@@ -61,18 +61,18 @@ fields:
|
||||
interface: file
|
||||
locked: true
|
||||
sort: 6
|
||||
translation:
|
||||
translations:
|
||||
locale: en-US
|
||||
translation: Login Foreground
|
||||
translations: Login Foreground
|
||||
width: half
|
||||
- collection: directus_settings
|
||||
field: public_background
|
||||
interface: file
|
||||
locked: true
|
||||
sort: 7
|
||||
translation:
|
||||
translations:
|
||||
locale: en-US
|
||||
translation: Login Background
|
||||
translations: Login Background
|
||||
width: half
|
||||
- collection: directus_settings
|
||||
field: public_note
|
||||
|
||||
@@ -9,11 +9,18 @@ fields:
|
||||
field: name
|
||||
interface: text-input
|
||||
locked: true
|
||||
options:
|
||||
iconRight: title
|
||||
sort: 1
|
||||
width: full
|
||||
- collection: directus_webhooks
|
||||
field: method
|
||||
interface: dropdown
|
||||
display: labels
|
||||
display_options:
|
||||
defaultBackground: "#ECEFF1"
|
||||
choices: null
|
||||
format: false
|
||||
locked: true
|
||||
options:
|
||||
choices:
|
||||
@@ -32,6 +39,20 @@ fields:
|
||||
- collection: directus_webhooks
|
||||
field: status
|
||||
interface: dropdown
|
||||
display: labels
|
||||
display_options:
|
||||
defaultColor: "#B0BEC5"
|
||||
defaultBackground: "#ECEFF1"
|
||||
showAsDot: true
|
||||
choices:
|
||||
- text: Active
|
||||
value: active
|
||||
foreground: "#607D8B"
|
||||
background: "#2F80ED"
|
||||
- text: Inactive
|
||||
value: inactive
|
||||
foreground: "#607D8B"
|
||||
background: "#ECEFF1"
|
||||
locked: true
|
||||
options:
|
||||
choices:
|
||||
@@ -46,8 +67,7 @@ fields:
|
||||
interface: toggle
|
||||
locked: true
|
||||
options:
|
||||
choices:
|
||||
label: Include item data in request
|
||||
label: Send Event Data
|
||||
sort: 5
|
||||
width: half
|
||||
- collection: directus_webhooks
|
||||
|
||||
@@ -30,12 +30,12 @@ fields:
|
||||
display_options:
|
||||
icon: true
|
||||
- collection: directus_activity
|
||||
field: action_on
|
||||
field: timestamp
|
||||
display: datetime
|
||||
options:
|
||||
relative: true
|
||||
- collection: directus_activity
|
||||
field: action_by
|
||||
field: user
|
||||
display: user
|
||||
- collection: directus_activity
|
||||
field: comment
|
||||
|
||||
@@ -11,5 +11,5 @@ hidden: false
|
||||
sort: null
|
||||
width: full
|
||||
group: null
|
||||
translation: null
|
||||
translations: null
|
||||
note: null
|
||||
|
||||
@@ -47,7 +47,7 @@ type FieldSeed = {
|
||||
sort: number | null;
|
||||
width: string | null;
|
||||
group: number | null;
|
||||
translation: Record<string, any> | null;
|
||||
translations: Record<string, any> | null;
|
||||
note: string | null;
|
||||
}[];
|
||||
};
|
||||
|
||||
@@ -9,11 +9,11 @@ import { clone } from 'lodash';
|
||||
dotenv.config();
|
||||
|
||||
const defaults: Record<string, any> = {
|
||||
PORT: 41201,
|
||||
PUBLIC_URL: 'http://localhost:41201',
|
||||
PORT: 8055,
|
||||
PUBLIC_URL: 'http://localhost:8055',
|
||||
|
||||
STORAGE_LOCATIONS: 'local',
|
||||
STORAGE_LOCAL_PUBLIC_URL: 'http://localhost:41201/uploads',
|
||||
STORAGE_LOCAL_PUBLIC_URL: 'http://localhost:8055/uploads',
|
||||
STORAGE_LOCAL_DRIVER: 'local',
|
||||
STORAGE_LOCAL_ROOT: './uploads',
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { FilterOperator } from '../types';
|
||||
|
||||
type FailedValidationExtensions = {
|
||||
field: string;
|
||||
type: FilterOperator;
|
||||
type: FilterOperator | 'required';
|
||||
valid?: number | string | (number | string)[];
|
||||
invalid?: number | string | (number | string)[];
|
||||
substring?: string;
|
||||
@@ -16,8 +16,6 @@ export class FailedValidationException extends BaseException {
|
||||
field: error.path[0] as string,
|
||||
};
|
||||
|
||||
console.log(error);
|
||||
|
||||
const joiType = error.type;
|
||||
|
||||
// eq | in | null | empty
|
||||
@@ -94,6 +92,11 @@ export class FailedValidationException extends BaseException {
|
||||
extensions.substring = error.context?.substring;
|
||||
}
|
||||
|
||||
// required
|
||||
if (joiType.endsWith('required')) {
|
||||
extensions.type = 'required';
|
||||
}
|
||||
|
||||
super(error.message, 400, 'FAILED_VALIDATION', extensions);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,15 +4,13 @@
|
||||
*/
|
||||
|
||||
import { RequestHandler } from 'express';
|
||||
import { Accountability, Query, Sort, Filter, Meta } from '../types';
|
||||
import logger from '../logger';
|
||||
import { parseFilter } from '../utils/parse-filter';
|
||||
import { sanitizeQuery } from '../utils/sanitize-query';
|
||||
|
||||
const sanitizeQuery: RequestHandler = (req, res, next) => {
|
||||
const sanitizeQueryMiddleware: RequestHandler = (req, res, next) => {
|
||||
req.sanitizedQuery = {};
|
||||
if (!req.query) return;
|
||||
|
||||
req.sanitizedQuery = sanitize(
|
||||
req.sanitizedQuery = sanitizeQuery(
|
||||
{
|
||||
fields: req.query.fields || '*',
|
||||
...req.query
|
||||
@@ -25,143 +23,5 @@ const sanitizeQuery: RequestHandler = (req, res, next) => {
|
||||
return next();
|
||||
};
|
||||
|
||||
function sanitize(rawQuery: Record<string, any>, accountability: Accountability | null) {
|
||||
const query: Query = {};
|
||||
export default sanitizeQueryMiddleware;
|
||||
|
||||
if (rawQuery.limit !== undefined) {
|
||||
const limit = sanitizeLimit(rawQuery.limit);
|
||||
|
||||
if (typeof limit === 'number') {
|
||||
query.limit = limit;
|
||||
}
|
||||
}
|
||||
|
||||
if (rawQuery.fields) {
|
||||
query.fields = sanitizeFields(rawQuery.fields);
|
||||
}
|
||||
|
||||
if (rawQuery.sort) {
|
||||
query.sort = sanitizeSort(rawQuery.sort);
|
||||
}
|
||||
|
||||
if (rawQuery.filter) {
|
||||
query.filter = sanitizeFilter(rawQuery.filter, accountability || null);
|
||||
}
|
||||
|
||||
if (rawQuery.limit == '-1') {
|
||||
delete query.limit;
|
||||
}
|
||||
|
||||
if (rawQuery.offset) {
|
||||
query.offset = sanitizeOffset(rawQuery.offset);
|
||||
}
|
||||
|
||||
if (rawQuery.page) {
|
||||
query.page = sanitizePage(rawQuery.page);
|
||||
}
|
||||
|
||||
if (rawQuery.single) {
|
||||
query.single = sanitizeSingle(rawQuery.single);
|
||||
}
|
||||
|
||||
if (rawQuery.meta) {
|
||||
query.meta = sanitizeMeta(rawQuery.meta);
|
||||
}
|
||||
|
||||
if (rawQuery.search && typeof rawQuery.search === 'string') {
|
||||
query.search = rawQuery.search;
|
||||
}
|
||||
|
||||
if (
|
||||
rawQuery.export &&
|
||||
typeof rawQuery.export === 'string' &&
|
||||
['json', 'csv'].includes(rawQuery.export)
|
||||
) {
|
||||
query.export = rawQuery.export as 'json' | 'csv';
|
||||
}
|
||||
|
||||
if (rawQuery.deep as Record<string, any>) {
|
||||
if (!query.deep) query.deep = {};
|
||||
|
||||
for (const [field, deepRawQuery] of Object.entries(rawQuery.deep)) {
|
||||
query.deep[field] = sanitize(deepRawQuery as any, accountability);
|
||||
}
|
||||
}
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
export default sanitizeQuery;
|
||||
|
||||
function sanitizeFields(rawFields: any) {
|
||||
if (!rawFields) return;
|
||||
|
||||
let fields: string[] = [];
|
||||
|
||||
if (typeof rawFields === 'string') fields = rawFields.split(',');
|
||||
else if (Array.isArray(rawFields)) fields = rawFields as string[];
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
function sanitizeSort(rawSort: any) {
|
||||
let fields: string[] = [];
|
||||
|
||||
if (typeof rawSort === 'string') fields = rawSort.split(',');
|
||||
else if (Array.isArray(rawSort)) fields = rawSort as string[];
|
||||
|
||||
return fields.map((field) => {
|
||||
const order = field.startsWith('-') ? 'desc' : 'asc';
|
||||
const column = field.startsWith('-') ? field.substring(1) : field;
|
||||
return { column, order } as Sort;
|
||||
});
|
||||
}
|
||||
|
||||
function sanitizeFilter(rawFilter: any, accountability: Accountability | null) {
|
||||
let filters: Filter = rawFilter;
|
||||
|
||||
if (typeof rawFilter === 'string') {
|
||||
try {
|
||||
filters = JSON.parse(rawFilter);
|
||||
} catch {
|
||||
logger.warn('Invalid value passed for filter query parameter.');
|
||||
}
|
||||
}
|
||||
|
||||
filters = parseFilter(filters, accountability);
|
||||
|
||||
return filters;
|
||||
}
|
||||
|
||||
function sanitizeLimit(rawLimit: any) {
|
||||
if (rawLimit === undefined || rawLimit === null) return null;
|
||||
return Number(rawLimit);
|
||||
}
|
||||
|
||||
function sanitizeOffset(rawOffset: any) {
|
||||
return Number(rawOffset);
|
||||
}
|
||||
|
||||
function sanitizePage(rawPage: any) {
|
||||
return Number(rawPage);
|
||||
}
|
||||
|
||||
function sanitizeSingle(rawSingle: any) {
|
||||
return true;
|
||||
}
|
||||
|
||||
function sanitizeMeta(rawMeta: any) {
|
||||
if (rawMeta === '*') {
|
||||
return Object.values(Meta);
|
||||
}
|
||||
|
||||
if (rawMeta.includes(',')) {
|
||||
return rawMeta.split(',');
|
||||
}
|
||||
|
||||
if (Array.isArray(rawMeta)) {
|
||||
return rawMeta;
|
||||
}
|
||||
|
||||
return [rawMeta];
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { validateDBConnection } from './database';
|
||||
export default async function start() {
|
||||
await validateDBConnection();
|
||||
|
||||
const port = env.NODE_ENV === 'development' ? 41201 : env.PORT;
|
||||
const port = env.NODE_ENV === 'development' ? 8055 : env.PORT;
|
||||
|
||||
app.listen(port, () => {
|
||||
logger.info(`Server started at port ${port}`);
|
||||
|
||||
@@ -93,7 +93,7 @@ export class AuthenticationService {
|
||||
if (this.accountability) {
|
||||
await this.activityService.create({
|
||||
action: Action.AUTHENTICATE,
|
||||
action_by: user.id,
|
||||
user: user.id,
|
||||
ip: this.accountability.ip,
|
||||
user_agent: this.accountability.userAgent,
|
||||
collection: 'directus_users',
|
||||
@@ -181,4 +181,22 @@ export class AuthenticationService {
|
||||
const secret = user.tfa_secret;
|
||||
return authenticator.check(otp, secret);
|
||||
}
|
||||
|
||||
async verifyPassword(pk: string, password: string) {
|
||||
const userRecord = await this.knex
|
||||
.select('password')
|
||||
.from('directus_users')
|
||||
.where({ id: pk })
|
||||
.first();
|
||||
|
||||
if (!userRecord || !userRecord.password) {
|
||||
throw new InvalidCredentialsException();
|
||||
}
|
||||
|
||||
if ((await argon2.verify(userRecord.password, password)) === false) {
|
||||
throw new InvalidCredentialsException();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,9 +11,10 @@ import {
|
||||
Item,
|
||||
PrimaryKey,
|
||||
} from '../types';
|
||||
import SchemaInspector from 'knex-schema-inspector';
|
||||
import Knex from 'knex';
|
||||
import { ForbiddenException, FailedValidationException } from '../exceptions';
|
||||
import { uniq, merge } from 'lodash';
|
||||
import { uniq, merge, flatten } from 'lodash';
|
||||
import generateJoi from '../utils/generate-joi';
|
||||
import { ItemsService } from './items';
|
||||
import { parseFilter } from '../utils/parse-filter';
|
||||
@@ -190,29 +191,39 @@ export class AuthorizationService {
|
||||
collection: string,
|
||||
payload: Partial<Item>[] | Partial<Item>
|
||||
): Promise<Partial<Item>[] | Partial<Item>> {
|
||||
const validationErrors: FailedValidationException[] = [];
|
||||
|
||||
let payloads = Array.isArray(payload) ? payload : [payload];
|
||||
|
||||
const permission = await this.knex
|
||||
.select<Permission>('*')
|
||||
.from('directus_permissions')
|
||||
.where({ action, collection, role: this.accountability?.role || null })
|
||||
.first();
|
||||
let permission: Permission | undefined;
|
||||
|
||||
if (!permission) throw new ForbiddenException();
|
||||
if (this.accountability?.admin === true) {
|
||||
permission = { id: 0, role: this.accountability?.role, collection, action, permissions: {}, validation: {}, limit: null, fields: '*', presets: {}, }
|
||||
} else {
|
||||
permission = await this.knex
|
||||
.select<Permission>('*')
|
||||
.from('directus_permissions')
|
||||
.where({ action, collection, role: this.accountability?.role || null })
|
||||
.first();
|
||||
|
||||
const allowedFields = permission.fields?.split(',') || [];
|
||||
// Check if you have permission to access the fields you're trying to acces
|
||||
|
||||
if (allowedFields.includes('*') === false) {
|
||||
for (const payload of payloads) {
|
||||
const keysInData = Object.keys(payload);
|
||||
const invalidKeys = keysInData.filter(
|
||||
(fieldKey) => allowedFields.includes(fieldKey) === false
|
||||
);
|
||||
if (!permission) throw new ForbiddenException();
|
||||
|
||||
if (invalidKeys.length > 0) {
|
||||
throw new ForbiddenException(
|
||||
`You're not allowed to ${action} field "${invalidKeys[0]}" in collection "${collection}".`
|
||||
const allowedFields = permission.fields?.split(',') || [];
|
||||
|
||||
if (allowedFields.includes('*') === false) {
|
||||
for (const payload of payloads) {
|
||||
const keysInData = Object.keys(payload);
|
||||
const invalidKeys = keysInData.filter(
|
||||
(fieldKey) => allowedFields.includes(fieldKey) === false
|
||||
);
|
||||
|
||||
if (invalidKeys.length > 0) {
|
||||
throw new ForbiddenException(
|
||||
`You're not allowed to ${action} field "${invalidKeys[0]}" in collection "${collection}".`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -221,16 +232,49 @@ export class AuthorizationService {
|
||||
|
||||
payloads = payloads.map((payload) => merge({}, preset, payload));
|
||||
|
||||
const schema = generateJoi(permission.validation);
|
||||
const schemaInspector = SchemaInspector(this.knex);
|
||||
const columns = await schemaInspector.columnInfo(collection);
|
||||
|
||||
for (const payload of payloads) {
|
||||
const { error } = schema.validate(payload, { abortEarly: false });
|
||||
let requiredColumns: string[] = [];
|
||||
|
||||
if (error) {
|
||||
throw error.details.map((details) => new FailedValidationException(details));
|
||||
for (const column of columns) {
|
||||
const field = await this.knex.select<{ special: string }>('special').from('directus_fields').where({ collection, field: column.name }).first();
|
||||
const specials = (field?.special || '').split(',');
|
||||
const hasGenerateSpecial = ['uuid', 'date-created', 'role-created', 'user-created'].some((name) => specials.includes(name));
|
||||
const isRequired = column.is_nullable === false && column.has_auto_increment === false && column.default_value === null && hasGenerateSpecial === false;
|
||||
|
||||
if (isRequired) {
|
||||
requiredColumns.push(column.name);
|
||||
}
|
||||
}
|
||||
|
||||
if (requiredColumns.length > 0) {
|
||||
permission.validation = {
|
||||
_and: [
|
||||
permission.validation,
|
||||
{}
|
||||
]
|
||||
}
|
||||
|
||||
if (action === 'create') {
|
||||
for (const name of requiredColumns) {
|
||||
permission.validation._and[1][name] = {
|
||||
_required: true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const name of requiredColumns) {
|
||||
permission.validation._and[1][name] = {
|
||||
_nnull: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
validationErrors.push(...this.validateJoi(permission.validation, payloads));
|
||||
|
||||
if (validationErrors.length > 0) throw validationErrors;
|
||||
|
||||
if (Array.isArray(payload)) {
|
||||
return payloads;
|
||||
} else {
|
||||
@@ -238,11 +282,49 @@ export class AuthorizationService {
|
||||
}
|
||||
}
|
||||
|
||||
validateJoi(validation: Record<string, any>, payloads: Partial<Record<string, any>>[]): FailedValidationException[] {
|
||||
const errors: FailedValidationException[] = [];
|
||||
|
||||
/**
|
||||
* Note there can only be a single _and / _or per level
|
||||
*/
|
||||
|
||||
if (Object.keys(validation)[0] === '_and') {
|
||||
const subValidation = Object.values(validation)[0];
|
||||
const nestedErrors = flatten<FailedValidationException>(subValidation.map((subObj: Record<string, any>) => this.validateJoi(subObj, payloads))).filter((err?: FailedValidationException) => err);
|
||||
errors.push(...nestedErrors);
|
||||
}
|
||||
|
||||
if (Object.keys(validation)[0] === '_or') {
|
||||
const subValidation = Object.values(validation)[0];
|
||||
const nestedErrors = flatten<FailedValidationException>(subValidation.map((subObj: Record<string, any>) => this.validateJoi(subObj, payloads)));
|
||||
const allErrored = nestedErrors.every((err?: FailedValidationException) => err);
|
||||
|
||||
if (allErrored) {
|
||||
errors.push(...nestedErrors);
|
||||
}
|
||||
}
|
||||
|
||||
const schema = generateJoi(validation);
|
||||
|
||||
for (const payload of payloads) {
|
||||
const { error } = schema.validate(payload, { abortEarly: false });
|
||||
|
||||
if (error) {
|
||||
errors.push(...error.details.map((details) => new FailedValidationException(details)));
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
async checkAccess(
|
||||
action: PermissionsAction,
|
||||
collection: string,
|
||||
pk: PrimaryKey | PrimaryKey[]
|
||||
) {
|
||||
if (this.accountability?.admin === true) return;
|
||||
|
||||
const itemsService = new ItemsService(collection, { accountability: this.accountability });
|
||||
|
||||
try {
|
||||
|
||||
389
api/src/services/graphql.ts
Normal file
389
api/src/services/graphql.ts
Normal file
@@ -0,0 +1,389 @@
|
||||
import Knex from 'knex';
|
||||
import database from '../database';
|
||||
import { AbstractServiceOptions, Accountability, Collection, Field, Relation, Query, AbstractService } from '../types';
|
||||
import { GraphQLString, GraphQLSchema, GraphQLObjectType, GraphQLList, GraphQLResolveInfo, GraphQLInputObjectType, ObjectFieldNode, GraphQLID, ValueNode, FieldNode, GraphQLFieldConfigMap, GraphQLInt, IntValueNode, StringValueNode, BooleanValueNode, ArgumentNode, GraphQLScalarType, GraphQLBoolean, ObjectValueNode } from 'graphql';
|
||||
import { getGraphQLType } from '../utils/get-graphql-type';
|
||||
import { RelationsService } from './relations';
|
||||
import { ItemsService } from './items';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { sanitizeQuery } from '../utils/sanitize-query';
|
||||
|
||||
import { ActivityService } from './activity';
|
||||
import { CollectionsService } from './collections';
|
||||
import { FieldsService } from './fields';
|
||||
import { FilesService } from './files';
|
||||
import { FoldersService } from './folders';
|
||||
import { PermissionsService } from './permissions';
|
||||
import { PresetsService } from './presets';
|
||||
import { RevisionsService } from './revisions';
|
||||
import { RolesService } from './roles';
|
||||
import { SettingsService } from './settings';
|
||||
import { UsersService } from './users';
|
||||
import { WebhooksService } from './webhooks';
|
||||
|
||||
export class GraphQLService {
|
||||
accountability: Accountability | null;
|
||||
knex: Knex;
|
||||
fieldsService: FieldsService;
|
||||
collectionsService: CollectionsService;
|
||||
relationsService: RelationsService;
|
||||
|
||||
constructor(options?: AbstractServiceOptions) {
|
||||
this.accountability = options?.accountability || null;
|
||||
this.knex = options?.knex || database;
|
||||
this.fieldsService = new FieldsService(options);
|
||||
this.collectionsService = new CollectionsService(options);
|
||||
this.relationsService = new RelationsService({ knex: this.knex });
|
||||
}
|
||||
|
||||
args = {
|
||||
sort: {
|
||||
type: GraphQLString
|
||||
},
|
||||
limit: {
|
||||
type: GraphQLInt,
|
||||
},
|
||||
offset: {
|
||||
type: GraphQLInt,
|
||||
},
|
||||
page: {
|
||||
type: GraphQLInt,
|
||||
},
|
||||
search: {
|
||||
type: GraphQLString,
|
||||
}
|
||||
}
|
||||
|
||||
async getSchema() {
|
||||
const collectionsInSystem = await this.collectionsService.readByQuery();
|
||||
const fieldsInSystem = await this.fieldsService.readAll();
|
||||
const relationsInSystem = await this.relationsService.readByQuery({}) as Relation[];
|
||||
|
||||
const schema = this.getGraphQLSchema(collectionsInSystem, fieldsInSystem, relationsInSystem);
|
||||
|
||||
return schema;
|
||||
}
|
||||
|
||||
getGraphQLSchema(collections: Collection[], fields: Field[], relations: Relation[]) {
|
||||
const filterTypes = this.getFilterArgs(collections, fields, relations);
|
||||
const schema: any = { items: {} };
|
||||
|
||||
for (const collection of collections) {
|
||||
const systemCollection = collection.collection.startsWith('directus_');
|
||||
|
||||
const schemaSection: any = {
|
||||
type: new GraphQLObjectType({
|
||||
name: collection.collection,
|
||||
description: collection.meta?.note,
|
||||
fields: () => {
|
||||
const fieldsObject: GraphQLFieldConfigMap<any, any> = {};
|
||||
const fieldsInCollection = fields.filter((field) => field.collection === collection.collection);
|
||||
|
||||
for (const field of fieldsInCollection) {
|
||||
const relationForField = relations.find((relation) => {
|
||||
return relation.many_collection === collection.collection && relation.many_field === field.field ||
|
||||
relation.one_collection === collection.collection && relation.one_field === field.field;
|
||||
});
|
||||
|
||||
if (relationForField) {
|
||||
const isM2O = relationForField.many_collection === collection.collection && relationForField.many_field === field.field;
|
||||
|
||||
if (isM2O) {
|
||||
const relatedIsSystem = relationForField.one_collection.startsWith('directus_');
|
||||
const relatedType = relatedIsSystem ? schema[relationForField.one_collection.substring(9)].type : schema.items[relationForField.one_collection].type;
|
||||
|
||||
fieldsObject[field.field] = {
|
||||
type: relatedType,
|
||||
}
|
||||
} else {
|
||||
const relatedIsSystem = relationForField.many_collection.startsWith('directus_');
|
||||
const relatedType = relatedIsSystem ? schema[relationForField.many_collection.substring(9)].type : schema.items[relationForField.many_collection].type;
|
||||
|
||||
fieldsObject[field.field] = {
|
||||
type: new GraphQLList(relatedType),
|
||||
args: {
|
||||
...this.args,
|
||||
filter: {
|
||||
type: filterTypes[relationForField.many_collection],
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fieldsObject[field.field] = {
|
||||
type: field.schema?.is_primary_key ? GraphQLID : getGraphQLType(field.type),
|
||||
}
|
||||
}
|
||||
|
||||
fieldsObject[field.field].description = field.meta?.note;
|
||||
}
|
||||
|
||||
return fieldsObject;
|
||||
},
|
||||
}),
|
||||
resolve: (source: any, args: any, context: any, info: GraphQLResolveInfo) => this.resolve(info),
|
||||
args: {
|
||||
...this.args,
|
||||
filter: {
|
||||
name: `${collection.collection}_filter`,
|
||||
type: filterTypes[collection.collection],
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (systemCollection) {
|
||||
schema[collection.collection.substring(9)] = schemaSection;
|
||||
} else {
|
||||
schema.items[collection.collection] = schemaSection;
|
||||
}
|
||||
}
|
||||
|
||||
const schemaWithLists = cloneDeep(schema);
|
||||
|
||||
for (const collection of collections) {
|
||||
if (collection.meta?.singleton !== true) {
|
||||
const systemCollection = collection.collection.startsWith('directus_');
|
||||
|
||||
if (systemCollection) {
|
||||
schemaWithLists[collection.collection.substring(9)].type = new GraphQLList(schemaWithLists[collection.collection.substring(9)].type);
|
||||
} else {
|
||||
schemaWithLists.items[collection.collection].type = new GraphQLList(schemaWithLists.items[collection.collection].type);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
schemaWithLists.items = {
|
||||
type: new GraphQLObjectType({
|
||||
name: 'items',
|
||||
fields: schemaWithLists.items,
|
||||
}),
|
||||
resolve: () => ({}),
|
||||
};
|
||||
|
||||
return new GraphQLSchema({
|
||||
query: new GraphQLObjectType({
|
||||
name: 'Directus',
|
||||
fields: schemaWithLists,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
getFilterArgs(collections: Collection[], fields: Field[], relations: Relation[]) {
|
||||
const filterTypes: any = {};
|
||||
|
||||
for (const collection of collections) {
|
||||
filterTypes[collection.collection] = new GraphQLInputObjectType({
|
||||
name: `${collection.collection}_filter`,
|
||||
fields: () => {
|
||||
const filterFields: any = {
|
||||
_and: {
|
||||
type: new GraphQLList(filterTypes[collection.collection])
|
||||
},
|
||||
_or: {
|
||||
type: new GraphQLList(filterTypes[collection.collection])
|
||||
},
|
||||
};
|
||||
|
||||
const fieldsInCollection = fields.filter((field) => field.collection === collection.collection);
|
||||
|
||||
for (const field of fieldsInCollection) {
|
||||
const relationForField = relations.find((relation) => {
|
||||
return relation.many_collection === collection.collection && relation.many_field === field.field ||
|
||||
relation.one_collection === collection.collection && relation.one_field === field.field;
|
||||
});
|
||||
|
||||
if (relationForField) {
|
||||
const isM2O = relationForField.many_collection === collection.collection && relationForField.many_field === field.field;
|
||||
|
||||
if (isM2O) {
|
||||
const relatedType = filterTypes[relationForField.one_collection];
|
||||
|
||||
filterFields[field.field] = {
|
||||
type: relatedType,
|
||||
}
|
||||
} else {
|
||||
const relatedType = filterTypes[relationForField.many_collection];
|
||||
|
||||
filterFields[field.field] = {
|
||||
type: relatedType
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const fieldType = field.schema?.is_primary_key ? GraphQLID : getGraphQLType(field.type);
|
||||
|
||||
filterFields[field.field] = {
|
||||
type: new GraphQLInputObjectType({
|
||||
name: `${collection.collection}_${field.field}_filter_operators`,
|
||||
fields: {
|
||||
/* @todo make this a little smarter by only including filters that work with current type */
|
||||
_eq: {
|
||||
type: fieldType,
|
||||
},
|
||||
_neq: {
|
||||
type: fieldType
|
||||
},
|
||||
_contains: {
|
||||
type: fieldType,
|
||||
},
|
||||
_ncontains: {
|
||||
type: fieldType,
|
||||
},
|
||||
_in: {
|
||||
type: new GraphQLList(fieldType),
|
||||
},
|
||||
_nin: {
|
||||
type: new GraphQLList(fieldType),
|
||||
},
|
||||
_gt: {
|
||||
type: fieldType,
|
||||
},
|
||||
_gte: {
|
||||
type: fieldType,
|
||||
},
|
||||
_lt: {
|
||||
type: fieldType,
|
||||
},
|
||||
_lte: {
|
||||
type: fieldType,
|
||||
},
|
||||
_null: {
|
||||
type: GraphQLBoolean,
|
||||
},
|
||||
_nnull: {
|
||||
type: GraphQLBoolean,
|
||||
},
|
||||
_empty: {
|
||||
type: GraphQLBoolean,
|
||||
},
|
||||
_nempty: {
|
||||
type: GraphQLBoolean,
|
||||
}
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return filterFields;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return filterTypes
|
||||
}
|
||||
|
||||
async resolve(info: GraphQLResolveInfo) {
|
||||
const systemField = info.path.prev?.key !== 'items';
|
||||
|
||||
const collection = systemField ? `directus_${info.fieldName}` : info.fieldName;
|
||||
const selections = info.fieldNodes[0]?.selectionSet?.selections?.filter((node) => node.kind === 'Field') as FieldNode[] | undefined;
|
||||
if (!selections) return null;
|
||||
|
||||
return await this.getData(collection, selections, info.fieldNodes[0].arguments);
|
||||
}
|
||||
|
||||
async getData(collection: string, selections: FieldNode[], argsArray?: readonly ArgumentNode[]) {
|
||||
const args: Record<string, any> = this.parseArgs(argsArray);
|
||||
|
||||
const query: Query = sanitizeQuery(args, this.accountability);
|
||||
|
||||
const parseFields = (selections: FieldNode[], parent?: string): string[] => {
|
||||
const fields: string[] = [];
|
||||
|
||||
for (const selection of selections) {
|
||||
const current = parent ? `${parent}.${selection.name.value}` : selection.name.value;
|
||||
|
||||
if (selection.selectionSet === undefined) {
|
||||
fields.push(current);
|
||||
} else {
|
||||
const children = parseFields(selection.selectionSet.selections.filter((selection) => selection.kind === 'Field') as FieldNode[], current);
|
||||
fields.push(...children);
|
||||
}
|
||||
|
||||
if (selection.arguments && selection.arguments.length > 0) {
|
||||
if (!query.deep) query.deep = {};
|
||||
|
||||
const args: Record<string, any> = this.parseArgs(selection.arguments);
|
||||
query.deep[current] = sanitizeQuery(args, this.accountability);
|
||||
}
|
||||
}
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
query.fields = parseFields(selections.filter((selection) => selection.kind === 'Field') as FieldNode[]);
|
||||
|
||||
let service: ItemsService;
|
||||
|
||||
switch (collection) {
|
||||
case 'directus_activity':
|
||||
service = new ActivityService({ knex: this.knex, accountability: this.accountability });
|
||||
// case 'directus_collections':
|
||||
// service = new CollectionsService({ knex: this.knex, accountability: this.accountability });
|
||||
// case 'directus_fields':
|
||||
// service = new FieldsService({ knex: this.knex, accountability: this.accountability });
|
||||
case 'directus_files':
|
||||
service = new FilesService({ knex: this.knex, accountability: this.accountability });
|
||||
case 'directus_folders':
|
||||
service = new FoldersService({ knex: this.knex, accountability: this.accountability });
|
||||
case 'directus_folders':
|
||||
service = new FoldersService({ knex: this.knex, accountability: this.accountability });
|
||||
case 'directus_permissions':
|
||||
service = new PermissionsService({ knex: this.knex, accountability: this.accountability });
|
||||
case 'directus_presets':
|
||||
service = new PresetsService({ knex: this.knex, accountability: this.accountability });
|
||||
case 'directus_relations':
|
||||
service = new RelationsService({ knex: this.knex, accountability: this.accountability });
|
||||
case 'directus_revisions':
|
||||
service = new RevisionsService({ knex: this.knex, accountability: this.accountability });
|
||||
case 'directus_roles':
|
||||
service = new RolesService({ knex: this.knex, accountability: this.accountability });
|
||||
case 'directus_settings':
|
||||
service = new SettingsService({ knex: this.knex, accountability: this.accountability });
|
||||
case 'directus_users':
|
||||
service = new UsersService({ knex: this.knex, accountability: this.accountability });
|
||||
case 'directus_webhooks':
|
||||
service = new WebhooksService({ knex: this.knex, accountability: this.accountability });
|
||||
default:
|
||||
service = new ItemsService(collection, { knex: this.knex, accountability: this.accountability });
|
||||
}
|
||||
|
||||
const collectionInfo = await this.knex.select('singleton').from('directus_collections').where({ collection: collection }).first();
|
||||
const result = collectionInfo?.singleton === true ? await service.readSingleton(query) : await service.readByQuery(query);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
parseArgs(args?: readonly ArgumentNode[] | readonly ObjectFieldNode[]): Record<string, any> {
|
||||
if (!args) return {};
|
||||
|
||||
const parseObjectValue = (arg: ObjectValueNode) => {
|
||||
return this.parseArgs(arg.fields);
|
||||
}
|
||||
|
||||
const argsObject: any = {};
|
||||
|
||||
for (const argument of args) {
|
||||
if (argument.value.kind === 'ObjectValue') {
|
||||
argsObject[argument.name.value] = parseObjectValue(argument.value);
|
||||
} else if (argument.value.kind === 'ListValue') {
|
||||
const values: any = [];
|
||||
|
||||
for (const valueNode of argument.value.values) {
|
||||
if (valueNode.kind === 'ObjectValue') {
|
||||
values.push(this.parseArgs(valueNode.fields));
|
||||
} else {
|
||||
values.push((valueNode as any).value);
|
||||
}
|
||||
}
|
||||
|
||||
argsObject[argument.name.value] = values;
|
||||
} else {
|
||||
argsObject[argument.name.value] = (argument.value as IntValueNode | StringValueNode | BooleanValueNode).value;
|
||||
}
|
||||
}
|
||||
|
||||
return argsObject;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ export * from './collections';
|
||||
export * from './fields';
|
||||
export * from './files';
|
||||
export * from './folders';
|
||||
export * from './graphql';
|
||||
export * from './items';
|
||||
export * from './meta';
|
||||
export * from './payload';
|
||||
|
||||
@@ -34,7 +34,9 @@ export class ItemsService implements AbstractService {
|
||||
this.collection = collection;
|
||||
this.knex = options?.knex || database;
|
||||
this.accountability = options?.accountability || null;
|
||||
this.eventScope = this.collection.startsWith('directus_') ? this.collection.substring(9) : 'item';
|
||||
this.eventScope = this.collection.startsWith('directus_')
|
||||
? this.collection.substring(9)
|
||||
: 'items';
|
||||
|
||||
return this;
|
||||
}
|
||||
@@ -71,7 +73,7 @@ export class ItemsService implements AbstractService {
|
||||
payloads = customProcessed[customProcessed.length - 1];
|
||||
}
|
||||
|
||||
if (this.accountability && this.accountability.admin !== true) {
|
||||
if (this.accountability) {
|
||||
const authorizationService = new AuthorizationService({
|
||||
accountability: this.accountability,
|
||||
knex: trx,
|
||||
@@ -131,7 +133,7 @@ export class ItemsService implements AbstractService {
|
||||
if (this.accountability) {
|
||||
const activityRecords = primaryKeys.map((key) => ({
|
||||
action: Action.CREATE,
|
||||
action_by: this.accountability!.user,
|
||||
user: this.accountability!.user,
|
||||
collection: this.collection,
|
||||
ip: this.accountability!.ip,
|
||||
user_agent: this.accountability!.userAgent,
|
||||
@@ -206,7 +208,11 @@ export class ItemsService implements AbstractService {
|
||||
return records;
|
||||
}
|
||||
|
||||
readByKey(keys: PrimaryKey[], query?: Query, action?: PermissionsAction): Promise<null | Item[]>;
|
||||
readByKey(
|
||||
keys: PrimaryKey[],
|
||||
query?: Query,
|
||||
action?: PermissionsAction
|
||||
): Promise<null | Item[]>;
|
||||
readByKey(key: PrimaryKey, query?: Query, action?: PermissionsAction): Promise<null | Item>;
|
||||
async readByKey(
|
||||
key: PrimaryKey | PrimaryKey[],
|
||||
@@ -284,11 +290,13 @@ export class ItemsService implements AbstractService {
|
||||
payload = customProcessed[customProcessed.length - 1];
|
||||
}
|
||||
|
||||
if (this.accountability && this.accountability.admin !== true) {
|
||||
if (this.accountability) {
|
||||
const authorizationService = new AuthorizationService({
|
||||
accountability: this.accountability,
|
||||
});
|
||||
|
||||
await authorizationService.checkAccess('update', this.collection, keys);
|
||||
|
||||
payload = await authorizationService.validatePayload(
|
||||
'update',
|
||||
this.collection,
|
||||
@@ -327,7 +335,7 @@ export class ItemsService implements AbstractService {
|
||||
if (this.accountability) {
|
||||
const activityRecords = keys.map((key) => ({
|
||||
action: Action.UPDATE,
|
||||
action_by: this.accountability!.user,
|
||||
user: this.accountability!.user,
|
||||
collection: this.collection,
|
||||
ip: this.accountability!.ip,
|
||||
user_agent: this.accountability!.userAgent,
|
||||
@@ -339,11 +347,13 @@ export class ItemsService implements AbstractService {
|
||||
for (const activityRecord of activityRecords) {
|
||||
await trx.insert(activityRecord).into('directus_activity');
|
||||
let primaryKey;
|
||||
|
||||
const result = await trx
|
||||
.select('id')
|
||||
.from('directus_activity')
|
||||
.orderBy('id', 'desc')
|
||||
.first();
|
||||
|
||||
primaryKey = result.id;
|
||||
activityPrimaryKeys.push(primaryKey);
|
||||
}
|
||||
@@ -355,7 +365,10 @@ export class ItemsService implements AbstractService {
|
||||
activity: key,
|
||||
collection: this.collection,
|
||||
item: keys[index],
|
||||
data: JSON.stringify(snapshots?.[index]),
|
||||
data:
|
||||
snapshots && Array.isArray(snapshots)
|
||||
? JSON.stringify(snapshots?.[index])
|
||||
: snapshots,
|
||||
delta: JSON.stringify(payloadWithoutAliases),
|
||||
}));
|
||||
|
||||
@@ -438,7 +451,7 @@ export class ItemsService implements AbstractService {
|
||||
if (this.accountability) {
|
||||
const activityRecords = keys.map((key) => ({
|
||||
action: Action.DELETE,
|
||||
action_by: this.accountability!.user,
|
||||
user: this.accountability!.user,
|
||||
collection: this.collection,
|
||||
ip: this.accountability!.ip,
|
||||
user_agent: this.accountability!.userAgent,
|
||||
@@ -472,7 +485,7 @@ export class ItemsService implements AbstractService {
|
||||
const schemaInspector = SchemaInspector(this.knex);
|
||||
query.single = true;
|
||||
|
||||
const record = await this.readByQuery(query) as Item;
|
||||
const record = (await this.readByQuery(query)) as Item;
|
||||
|
||||
if (!record) {
|
||||
const columns = await schemaInspector.columnInfo(this.collection);
|
||||
|
||||
@@ -301,11 +301,10 @@ export class PayloadService {
|
||||
const relatedRecord: Partial<Item> = payload[relation.many_field];
|
||||
const hasPrimaryKey = relatedRecord.hasOwnProperty(relation.one_primary);
|
||||
|
||||
let relatedPrimaryKey: PrimaryKey;
|
||||
|
||||
if (hasPrimaryKey) {
|
||||
relatedPrimaryKey = relatedRecord[relation.one_primary];
|
||||
let relatedPrimaryKey: PrimaryKey = relatedRecord[relation.one_primary];
|
||||
const exists = hasPrimaryKey && !!(await itemsService.readByKey(relatedPrimaryKey));
|
||||
|
||||
if (exists) {
|
||||
if (relatedRecord.hasOwnProperty('$delete') && relatedRecord.$delete) {
|
||||
await itemsService.delete(relatedPrimaryKey);
|
||||
} else {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ItemsService } from './items';
|
||||
import { AbstractServiceOptions } from '../types';
|
||||
import { AbstractServiceOptions, PrimaryKey, Revision } from '../types';
|
||||
import { InvalidPayloadException, ItemNotFoundException } from '../exceptions';
|
||||
|
||||
/**
|
||||
* @TODO only return data / delta based on permissions you have for the requested collection
|
||||
@@ -9,4 +10,18 @@ export class RevisionsService extends ItemsService {
|
||||
constructor(options?: AbstractServiceOptions) {
|
||||
super('directus_revisions', options);
|
||||
}
|
||||
|
||||
async revert(pk: PrimaryKey) {
|
||||
const revision = (await super.readByKey(pk)) as Revision | null;
|
||||
if (!revision) throw new ItemNotFoundException(pk, 'directus_revisions');
|
||||
|
||||
if (!revision.data)
|
||||
throw new InvalidPayloadException(`Revision doesn't contain data to revert to`);
|
||||
|
||||
const service = new ItemsService(revision.collection, {
|
||||
accountability: this.accountability,
|
||||
knex: this.knex,
|
||||
});
|
||||
await service.update(revision.data, revision.item);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,13 +24,13 @@ export class WebhooksService extends ItemsService {
|
||||
for (const webhook of webhooks) {
|
||||
if (webhook.actions === '*') {
|
||||
if (webhook.collections === '*') {
|
||||
const event = 'item.*.*';
|
||||
const event = 'items.*.*';
|
||||
const handler = this.createHandler(webhook);
|
||||
emitter.on(event, handler);
|
||||
registered.push({ event, handler });
|
||||
} else {
|
||||
for (const collection of webhook.collections.split(',')) {
|
||||
const event = `item.*.${collection}`;
|
||||
const event = `items.*.${collection}`;
|
||||
const handler = this.createHandler(webhook);
|
||||
emitter.on(event, handler);
|
||||
registered.push({ event, handler });
|
||||
@@ -39,13 +39,13 @@ export class WebhooksService extends ItemsService {
|
||||
} else {
|
||||
for (const action of webhook.actions.split(',')) {
|
||||
if (webhook.collections === '*') {
|
||||
const event = `item.${action}.*`;
|
||||
const event = `items.${action}.*`;
|
||||
const handler = this.createHandler(webhook);
|
||||
emitter.on(event, handler);
|
||||
registered.push({ event, handler });
|
||||
} else {
|
||||
for (const collection of webhook.collections.split(',')) {
|
||||
const event = `item.${action}.${collection}`;
|
||||
const event = `items.${action}.${collection}`;
|
||||
const handler = this.createHandler(webhook);
|
||||
emitter.on(event, handler);
|
||||
registered.push({ event, handler });
|
||||
|
||||
@@ -8,9 +8,9 @@ export type Collection = {
|
||||
collection: string;
|
||||
note: string | null;
|
||||
hidden: boolean;
|
||||
single: boolean;
|
||||
singleton: boolean;
|
||||
icon: string | null;
|
||||
translation: Record<string, string>;
|
||||
translations: Record<string, string>;
|
||||
} | null;
|
||||
schema: Table;
|
||||
};
|
||||
|
||||
@@ -34,7 +34,7 @@ export type FieldMeta = {
|
||||
width: string | null;
|
||||
group: number | null;
|
||||
note: string | null;
|
||||
translation: null;
|
||||
translations: null;
|
||||
};
|
||||
|
||||
export type Field = {
|
||||
|
||||
@@ -11,6 +11,7 @@ export * from './meta';
|
||||
export * from './permissions';
|
||||
export * from './query';
|
||||
export * from './relation';
|
||||
export * from './revision';
|
||||
export * from './services';
|
||||
export * from './sessions';
|
||||
export * from './webhooks';
|
||||
|
||||
7
api/src/types/revision.ts
Normal file
7
api/src/types/revision.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export type Revision = {
|
||||
activity: number;
|
||||
collection: string;
|
||||
item: string | number;
|
||||
data: null | Record<string, any>;
|
||||
delta: null | Record<string, any>;
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { QueryBuilder } from 'knex';
|
||||
import { Query, Filter } from '../types';
|
||||
import database, { schemaInspector } from '../database';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { clone } from 'lodash';
|
||||
|
||||
export default async function applyQuery(collection: string, dbQuery: QueryBuilder, query: Query) {
|
||||
if (query.filter) {
|
||||
@@ -47,122 +47,137 @@ export default async function applyQuery(collection: string, dbQuery: QueryBuild
|
||||
}
|
||||
|
||||
export async function applyFilter(dbQuery: QueryBuilder, filter: Filter, collection: string) {
|
||||
for (let [key, value] of Object.entries(filter)) {
|
||||
// Nested relational filter
|
||||
if (key.includes('.')) {
|
||||
key = await applyJoins(dbQuery, key, collection);
|
||||
}
|
||||
|
||||
if (key.startsWith('_') === false) {
|
||||
let operator = Object.keys(value)[0];
|
||||
|
||||
const compareValue: any = Object.values(value)[0];
|
||||
|
||||
if (compareValue === '') continue;
|
||||
|
||||
if (operator === '_eq') {
|
||||
dbQuery.where({ [key]: compareValue });
|
||||
}
|
||||
|
||||
if (operator === '_neq') {
|
||||
dbQuery.whereNot({ [key]: compareValue });
|
||||
}
|
||||
|
||||
if (operator === '_contains') {
|
||||
dbQuery.where(key, 'like', `%${compareValue}%`);
|
||||
}
|
||||
|
||||
if (operator === '_ncontains') {
|
||||
dbQuery.where(key, 'like', `%${compareValue}%`);
|
||||
}
|
||||
|
||||
if (operator === '_gt') {
|
||||
dbQuery.where(key, '>', compareValue);
|
||||
}
|
||||
|
||||
if (operator === '_gte') {
|
||||
dbQuery.where(key, '>=', compareValue);
|
||||
}
|
||||
|
||||
if (operator === '_lt') {
|
||||
dbQuery.where(key, '<', compareValue);
|
||||
}
|
||||
|
||||
if (operator === '_lte') {
|
||||
dbQuery.where(key, '<=', compareValue);
|
||||
}
|
||||
|
||||
if (operator === '_in') {
|
||||
let value = compareValue;
|
||||
if (typeof value === 'string') value = value.split(',');
|
||||
|
||||
dbQuery.whereIn(key, value as string[]);
|
||||
}
|
||||
|
||||
if (operator === '_nin') {
|
||||
let value = compareValue;
|
||||
if (typeof value === 'string') value = value.split(',');
|
||||
|
||||
dbQuery.whereNotIn(key, value as string[]);
|
||||
}
|
||||
|
||||
if (operator === '_null') {
|
||||
dbQuery.whereNull(key);
|
||||
}
|
||||
|
||||
if (operator === '_nnull') {
|
||||
dbQuery.whereNotNull(key);
|
||||
}
|
||||
|
||||
if (operator === '_empty') {
|
||||
dbQuery.andWhere((query) => {
|
||||
query.whereNull(key);
|
||||
query.orWhere(key, '=', '');
|
||||
});
|
||||
}
|
||||
|
||||
if (operator === '_nempty') {
|
||||
dbQuery.andWhere((query) => {
|
||||
query.whereNotNull(key);
|
||||
query.orWhere(key, '!=', '');
|
||||
});
|
||||
}
|
||||
|
||||
if (operator === '_between') {
|
||||
let value = compareValue;
|
||||
if (typeof value === 'string') value = value.split(',');
|
||||
|
||||
dbQuery.whereBetween(key, value);
|
||||
}
|
||||
|
||||
if (operator === '_nbetween') {
|
||||
let value = compareValue;
|
||||
if (typeof value === 'string') value = value.split(',');
|
||||
|
||||
dbQuery.whereNotBetween(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(filter)) {
|
||||
if (key === '_or') {
|
||||
value.forEach((subFilter: Record<string, any>) => {
|
||||
dbQuery.orWhere((subQuery) => applyFilter(subQuery, subFilter, collection));
|
||||
});
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key === '_and') {
|
||||
value.forEach((subFilter: Record<string, any>) => {
|
||||
dbQuery.andWhere((subQuery) => applyFilter(subQuery, subFilter, collection));
|
||||
});
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
const filterPath = getFilterPath(key, value);
|
||||
const { operator: filterOperator, value: filterValue } = getOperation(key, value);
|
||||
|
||||
const column = filterPath.length > 1 ? await applyJoins(dbQuery, filterPath, collection) : `${collection}.${filterPath[0]}`;
|
||||
|
||||
applyFilterToQuery(column, filterOperator, filterValue);
|
||||
}
|
||||
|
||||
function applyFilterToQuery(key: string, operator: string, compareValue: any) {
|
||||
if (operator === '_eq') {
|
||||
dbQuery.where({ [key]: compareValue });
|
||||
}
|
||||
|
||||
if (operator === '_neq') {
|
||||
dbQuery.whereNot({ [key]: compareValue });
|
||||
}
|
||||
|
||||
if (operator === '_contains') {
|
||||
dbQuery.where(key, 'like', `%${compareValue}%`);
|
||||
}
|
||||
|
||||
if (operator === '_ncontains') {
|
||||
dbQuery.where(key, 'like', `%${compareValue}%`);
|
||||
}
|
||||
|
||||
if (operator === '_gt') {
|
||||
dbQuery.where(key, '>', compareValue);
|
||||
}
|
||||
|
||||
if (operator === '_gte') {
|
||||
dbQuery.where(key, '>=', compareValue);
|
||||
}
|
||||
|
||||
if (operator === '_lt') {
|
||||
dbQuery.where(key, '<', compareValue);
|
||||
}
|
||||
|
||||
if (operator === '_lte') {
|
||||
dbQuery.where(key, '<=', compareValue);
|
||||
}
|
||||
|
||||
if (operator === '_in') {
|
||||
let value = compareValue;
|
||||
if (typeof value === 'string') value = value.split(',');
|
||||
|
||||
dbQuery.whereIn(key, value as string[]);
|
||||
}
|
||||
|
||||
if (operator === '_nin') {
|
||||
let value = compareValue;
|
||||
if (typeof value === 'string') value = value.split(',');
|
||||
|
||||
dbQuery.whereNotIn(key, value as string[]);
|
||||
}
|
||||
|
||||
if (operator === '_null') {
|
||||
dbQuery.whereNull(key);
|
||||
}
|
||||
|
||||
if (operator === '_nnull') {
|
||||
dbQuery.whereNotNull(key);
|
||||
}
|
||||
|
||||
if (operator === '_empty') {
|
||||
dbQuery.andWhere((query) => {
|
||||
query.whereNull(key);
|
||||
query.orWhere(key, '=', '');
|
||||
});
|
||||
}
|
||||
|
||||
if (operator === '_nempty') {
|
||||
dbQuery.andWhere((query) => {
|
||||
query.whereNotNull(key);
|
||||
query.orWhere(key, '!=', '');
|
||||
});
|
||||
}
|
||||
|
||||
if (operator === '_between') {
|
||||
let value = compareValue;
|
||||
if (typeof value === 'string') value = value.split(',');
|
||||
|
||||
dbQuery.whereBetween(key, value);
|
||||
}
|
||||
|
||||
if (operator === '_nbetween') {
|
||||
let value = compareValue;
|
||||
if (typeof value === 'string') value = value.split(',');
|
||||
|
||||
dbQuery.whereNotBetween(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function applyJoins(dbQuery: QueryBuilder, path: string, collection: string) {
|
||||
const pathParts = path.split('.');
|
||||
function getFilterPath(key: string, value: Record<string, any>) {
|
||||
const path = [key];
|
||||
|
||||
if (Object.keys(value)[0].startsWith('_') === false) {
|
||||
path.push(...getFilterPath(Object.keys(value)[0], Object.values(value)[0]));
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
function getOperation(key: string, value: Record<string, any>): { operator: string, value: any } {
|
||||
if (key.startsWith('_') && key !== '_and' && key !== '_or') return { operator: key as string, value };
|
||||
return getOperation(Object.keys(value)[0], Object.values(value)[0]);
|
||||
}
|
||||
|
||||
async function applyJoins(dbQuery: QueryBuilder, path: string[], collection: string) {
|
||||
path = clone(path);
|
||||
|
||||
let keyName = '';
|
||||
|
||||
await addJoins(pathParts);
|
||||
await addJoins(path);
|
||||
|
||||
return keyName;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Filter } from '../types';
|
||||
import BaseJoi, { AnySchema } from 'joi';
|
||||
import BaseJoi, { AlternativesSchema, ObjectSchema, AnySchema } from 'joi';
|
||||
|
||||
const Joi: typeof BaseJoi = BaseJoi.extend({
|
||||
type: 'string',
|
||||
@@ -52,86 +52,97 @@ const Joi: typeof BaseJoi = BaseJoi.extend({
|
||||
},
|
||||
});
|
||||
|
||||
export default function generateJoi(filter: Filter | null) {
|
||||
export default function generateJoi(filter: Filter | null): AnySchema {
|
||||
filter = filter || {};
|
||||
|
||||
const schema: Record<string, AnySchema> = {};
|
||||
if (Object.keys(filter).length === 0) return Joi.any();
|
||||
|
||||
let schema: any;
|
||||
|
||||
for (const [key, value] of Object.entries(filter)) {
|
||||
const isField = key.startsWith('_') === false;
|
||||
if (key.startsWith('_') === false) {
|
||||
if (!schema) schema = {};
|
||||
|
||||
if (isField) {
|
||||
const operator = Object.keys(value)[0];
|
||||
const val = Object.keys(value)[1];
|
||||
|
||||
if (operator === '_eq') {
|
||||
schema[key] = Joi.any().equal(Object.values(value)[0]);
|
||||
}
|
||||
|
||||
if (operator === '_neq') {
|
||||
schema[key] = Joi.any().not(Object.values(value)[0]);
|
||||
}
|
||||
|
||||
if (operator === '_contains') {
|
||||
// @ts-ignore
|
||||
schema[key] = Joi.string().contains(Object.values(value)[0]);
|
||||
}
|
||||
|
||||
if (operator === '_ncontains') {
|
||||
// @ts-ignore
|
||||
schema[key] = Joi.string().ncontains(Object.values(value)[0]);
|
||||
}
|
||||
|
||||
if (operator === '_in') {
|
||||
schema[key] = Joi.any().equal(...(Object.values(value)[0] as (string | number)[]));
|
||||
}
|
||||
|
||||
if (operator === '_nin') {
|
||||
schema[key] = Joi.any().not(...(Object.values(value)[0] as (string | number)[]));
|
||||
}
|
||||
|
||||
if (operator === '_gt') {
|
||||
schema[key] = Joi.number().greater(Number(Object.values(value)[0]));
|
||||
}
|
||||
|
||||
if (operator === '_gte') {
|
||||
schema[key] = Joi.number().min(Number(Object.values(value)[0]));
|
||||
}
|
||||
|
||||
if (operator === '_lt') {
|
||||
schema[key] = Joi.number().less(Number(Object.values(value)[0]));
|
||||
}
|
||||
|
||||
if (operator === '_lte') {
|
||||
schema[key] = Joi.number().max(Number(Object.values(value)[0]));
|
||||
}
|
||||
|
||||
if (operator === '_null') {
|
||||
schema[key] = Joi.any().valid(null);
|
||||
}
|
||||
|
||||
if (operator === '_nnull') {
|
||||
schema[key] = Joi.any().invalid(null);
|
||||
}
|
||||
|
||||
if (operator === '_empty') {
|
||||
schema[key] = Joi.any().valid('');
|
||||
}
|
||||
|
||||
if (operator === '_nempty') {
|
||||
schema[key] = Joi.any().invalid('');
|
||||
}
|
||||
|
||||
if (operator === '_between') {
|
||||
const values = Object.values(value)[0] as number[];
|
||||
schema[key] = Joi.number().greater(values[0]).less(values[1]);
|
||||
}
|
||||
|
||||
if (operator === '_nbetween') {
|
||||
const values = Object.values(value)[0] as number[];
|
||||
schema[key] = Joi.number().less(values[0]).greater(values[1]);
|
||||
}
|
||||
schema[key] = getJoi(operator, val);
|
||||
}
|
||||
}
|
||||
|
||||
return Joi.object(schema).unknown();
|
||||
}
|
||||
|
||||
function getJoi(operator: string, value: any) {
|
||||
if (operator === '_eq') {
|
||||
return Joi.any().equal(Object.values(value)[0]);
|
||||
}
|
||||
|
||||
if (operator === '_neq') {
|
||||
return Joi.any().not(Object.values(value)[0]);
|
||||
}
|
||||
|
||||
if (operator === '_contains') {
|
||||
// @ts-ignore
|
||||
return Joi.string().contains(Object.values(value)[0]);
|
||||
}
|
||||
|
||||
if (operator === '_ncontains') {
|
||||
// @ts-ignore
|
||||
return Joi.string().ncontains(Object.values(value)[0]);
|
||||
}
|
||||
|
||||
if (operator === '_in') {
|
||||
return Joi.any().equal(...(Object.values(value)[0] as (string | number)[]));
|
||||
}
|
||||
|
||||
if (operator === '_nin') {
|
||||
return Joi.any().not(...(Object.values(value)[0] as (string | number)[]));
|
||||
}
|
||||
|
||||
if (operator === '_gt') {
|
||||
return Joi.number().greater(Number(Object.values(value)[0]));
|
||||
}
|
||||
|
||||
if (operator === '_gte') {
|
||||
return Joi.number().min(Number(Object.values(value)[0]));
|
||||
}
|
||||
|
||||
if (operator === '_lt') {
|
||||
return Joi.number().less(Number(Object.values(value)[0]));
|
||||
}
|
||||
|
||||
if (operator === '_lte') {
|
||||
return Joi.number().max(Number(Object.values(value)[0]));
|
||||
}
|
||||
|
||||
if (operator === '_null') {
|
||||
return Joi.any().valid(null);
|
||||
}
|
||||
|
||||
if (operator === '_nnull') {
|
||||
return Joi.any().invalid(null);
|
||||
}
|
||||
|
||||
if (operator === '_empty') {
|
||||
return Joi.any().valid('');
|
||||
}
|
||||
|
||||
if (operator === '_nempty') {
|
||||
return Joi.any().invalid('');
|
||||
}
|
||||
|
||||
if (operator === '_between') {
|
||||
const values = Object.values(value)[0] as number[];
|
||||
return Joi.number().greater(values[0]).less(values[1]);
|
||||
}
|
||||
|
||||
if (operator === '_nbetween') {
|
||||
const values = Object.values(value)[0] as number[];
|
||||
return Joi.number().less(values[0]).greater(values[1]);
|
||||
}
|
||||
|
||||
if (operator === '_required') {
|
||||
return Joi.invalid(null).required();
|
||||
}
|
||||
}
|
||||
|
||||
17
api/src/utils/get-graphql-type.ts
Normal file
17
api/src/utils/get-graphql-type.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { GraphQLBoolean, GraphQLFloat, GraphQLInt, GraphQLString } from 'graphql';
|
||||
import { types } from '../types';
|
||||
|
||||
export function getGraphQLType(localType: typeof types[number]) {
|
||||
switch (localType) {
|
||||
case 'boolean':
|
||||
return GraphQLBoolean;
|
||||
case 'bigInteger':
|
||||
case 'integer':
|
||||
return GraphQLInt;
|
||||
case 'decimal':
|
||||
case 'float':
|
||||
return GraphQLFloat;
|
||||
default:
|
||||
return GraphQLString;
|
||||
}
|
||||
}
|
||||
142
api/src/utils/sanitize-query.ts
Normal file
142
api/src/utils/sanitize-query.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { Accountability, Query, Sort, Filter, Meta } from '../types';
|
||||
import logger from '../logger';
|
||||
import { parseFilter } from '../utils/parse-filter';
|
||||
|
||||
export function sanitizeQuery(rawQuery: Record<string, any>, accountability: Accountability | null) {
|
||||
const query: Query = {};
|
||||
|
||||
if (rawQuery.limit !== undefined) {
|
||||
const limit = sanitizeLimit(rawQuery.limit);
|
||||
|
||||
if (typeof limit === 'number') {
|
||||
query.limit = limit;
|
||||
}
|
||||
}
|
||||
|
||||
if (rawQuery.fields) {
|
||||
query.fields = sanitizeFields(rawQuery.fields);
|
||||
}
|
||||
|
||||
if (rawQuery.sort) {
|
||||
query.sort = sanitizeSort(rawQuery.sort);
|
||||
}
|
||||
|
||||
if (rawQuery.filter) {
|
||||
query.filter = sanitizeFilter(rawQuery.filter, accountability || null);
|
||||
}
|
||||
|
||||
if (rawQuery.limit == '-1') {
|
||||
delete query.limit;
|
||||
}
|
||||
|
||||
if (rawQuery.offset) {
|
||||
query.offset = sanitizeOffset(rawQuery.offset);
|
||||
}
|
||||
|
||||
if (rawQuery.page) {
|
||||
query.page = sanitizePage(rawQuery.page);
|
||||
}
|
||||
|
||||
if (rawQuery.single) {
|
||||
query.single = sanitizeSingle(rawQuery.single);
|
||||
}
|
||||
|
||||
if (rawQuery.meta) {
|
||||
query.meta = sanitizeMeta(rawQuery.meta);
|
||||
}
|
||||
|
||||
if (rawQuery.search && typeof rawQuery.search === 'string') {
|
||||
query.search = rawQuery.search;
|
||||
}
|
||||
|
||||
if (
|
||||
rawQuery.export &&
|
||||
typeof rawQuery.export === 'string' &&
|
||||
['json', 'csv'].includes(rawQuery.export)
|
||||
) {
|
||||
query.export = rawQuery.export as 'json' | 'csv';
|
||||
}
|
||||
|
||||
if (rawQuery.deep as Record<string, any>) {
|
||||
if (!query.deep) query.deep = {};
|
||||
|
||||
for (const [field, deepRawQuery] of Object.entries(rawQuery.deep)) {
|
||||
query.deep[field] = sanitizeQuery(deepRawQuery as any, accountability);
|
||||
}
|
||||
}
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
function sanitizeFields(rawFields: any) {
|
||||
if (!rawFields) return;
|
||||
|
||||
let fields: string[] = [];
|
||||
|
||||
if (typeof rawFields === 'string') fields = rawFields.split(',');
|
||||
else if (Array.isArray(rawFields)) fields = rawFields as string[];
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
function sanitizeSort(rawSort: any) {
|
||||
let fields: string[] = [];
|
||||
|
||||
if (typeof rawSort === 'string') fields = rawSort.split(',');
|
||||
else if (Array.isArray(rawSort)) fields = rawSort as string[];
|
||||
|
||||
return fields.map((field) => {
|
||||
const order = field.startsWith('-') ? 'desc' : 'asc';
|
||||
const column = field.startsWith('-') ? field.substring(1) : field;
|
||||
return { column, order } as Sort;
|
||||
});
|
||||
}
|
||||
|
||||
function sanitizeFilter(rawFilter: any, accountability: Accountability | null) {
|
||||
let filters: Filter = rawFilter;
|
||||
|
||||
if (typeof rawFilter === 'string') {
|
||||
try {
|
||||
filters = JSON.parse(rawFilter);
|
||||
} catch {
|
||||
logger.warn('Invalid value passed for filter query parameter.');
|
||||
}
|
||||
}
|
||||
|
||||
filters = parseFilter(filters, accountability);
|
||||
|
||||
return filters;
|
||||
}
|
||||
|
||||
function sanitizeLimit(rawLimit: any) {
|
||||
if (rawLimit === undefined || rawLimit === null) return null;
|
||||
return Number(rawLimit);
|
||||
}
|
||||
|
||||
function sanitizeOffset(rawOffset: any) {
|
||||
return Number(rawOffset);
|
||||
}
|
||||
|
||||
function sanitizePage(rawPage: any) {
|
||||
return Number(rawPage);
|
||||
}
|
||||
|
||||
function sanitizeSingle(rawSingle: any) {
|
||||
return true;
|
||||
}
|
||||
|
||||
function sanitizeMeta(rawMeta: any) {
|
||||
if (rawMeta === '*') {
|
||||
return Object.values(Meta);
|
||||
}
|
||||
|
||||
if (rawMeta.includes(',')) {
|
||||
return rawMeta.split(',');
|
||||
}
|
||||
|
||||
if (Array.isArray(rawMeta)) {
|
||||
return rawMeta;
|
||||
}
|
||||
|
||||
return [rawMeta];
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"esModuleInterop": true,
|
||||
@@ -10,5 +11,8 @@
|
||||
"strict": true,
|
||||
"lib": ["es2019"],
|
||||
"skipLibCheck": true
|
||||
}
|
||||
},
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5648,9 +5648,9 @@
|
||||
"group": null,
|
||||
"length": "45"
|
||||
},
|
||||
"action_by": {
|
||||
"user": {
|
||||
"collection": "directus_activity",
|
||||
"field": "action_by",
|
||||
"field": "user",
|
||||
"datatype": "INT",
|
||||
"unique": false,
|
||||
"primary_key": false,
|
||||
@@ -5679,9 +5679,9 @@
|
||||
"group": null,
|
||||
"length": "10"
|
||||
},
|
||||
"action_on": {
|
||||
"timestamp": {
|
||||
"collection": "directus_activity",
|
||||
"field": "action_on",
|
||||
"field": "timestamp",
|
||||
"datatype": "DATETIME",
|
||||
"unique": false,
|
||||
"primary_key": false,
|
||||
@@ -7832,9 +7832,9 @@
|
||||
"group": null,
|
||||
"length": "191"
|
||||
},
|
||||
"parent_folder": {
|
||||
"parent": {
|
||||
"collection": "directus_folders",
|
||||
"field": "parent_folder",
|
||||
"field": "parent",
|
||||
"datatype": "INT",
|
||||
"unique": false,
|
||||
"primary_key": false,
|
||||
|
||||
@@ -1313,7 +1313,7 @@
|
||||
},
|
||||
{
|
||||
"collection": "directus_activity",
|
||||
"field": "action_by",
|
||||
"field": "user",
|
||||
"datatype": "INT",
|
||||
"unique": false,
|
||||
"primary_key": false,
|
||||
@@ -1345,7 +1345,7 @@
|
||||
},
|
||||
{
|
||||
"collection": "directus_activity",
|
||||
"field": "action_on",
|
||||
"field": "timestamp",
|
||||
"datatype": "DATETIME",
|
||||
"unique": false,
|
||||
"primary_key": false,
|
||||
@@ -3509,7 +3509,7 @@
|
||||
},
|
||||
{
|
||||
"collection": "directus_folders",
|
||||
"field": "parent_folder",
|
||||
"field": "parent",
|
||||
"datatype": "INT",
|
||||
"unique": false,
|
||||
"primary_key": false,
|
||||
|
||||
@@ -123,7 +123,7 @@
|
||||
{
|
||||
"id": 53,
|
||||
"collection_many": "directus_activity",
|
||||
"field_many": "action_by",
|
||||
"field_many": "user",
|
||||
"collection_one": "directus_users",
|
||||
"field_one": null,
|
||||
"junction_field": null
|
||||
@@ -155,7 +155,7 @@
|
||||
{
|
||||
"id": 59,
|
||||
"collection_many": "directus_folders",
|
||||
"field_many": "parent_folder",
|
||||
"field_many": "parent",
|
||||
"collection_one": "directus_folders",
|
||||
"field_one": null,
|
||||
"junction_field": null
|
||||
|
||||
27704
app/package-lock.json
generated
27704
app/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
101
app/package.json
101
app/package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@directus/app",
|
||||
"version": "9.0.0-beta.2",
|
||||
"version": "9.0.0-beta.8",
|
||||
"private": false,
|
||||
"description": "Directus is an Open-Source Headless CMS & API for Managing Custom Databases",
|
||||
"author": "Rijk van Zanten <rijk@rngr.org>",
|
||||
@@ -30,7 +30,7 @@
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@directus/docs": "^9.0.0-beta.2",
|
||||
"@directus/docs": "file:../docs",
|
||||
"@directus/format-title": "^3.2.0",
|
||||
"@popperjs/core": "^2.4.3",
|
||||
"@sindresorhus/slugify": "^1.0.0",
|
||||
@@ -81,92 +81,17 @@
|
||||
"vue-router": "^3.3.4",
|
||||
"vuedraggable": "^2.24.1"
|
||||
},
|
||||
"gitHead": "4476da28dbbc2824e680137aa28b2b91b5afabec",
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.10.5",
|
||||
"@babel/plugin-proposal-optional-chaining": "^7.10.4",
|
||||
"@storybook/addon-actions": "^5.3.19",
|
||||
"@storybook/addon-centered": "^5.3.19",
|
||||
"@storybook/addon-knobs": "^5.3.19",
|
||||
"@storybook/addon-links": "^5.3.19",
|
||||
"@storybook/addon-notes": "^5.3.19",
|
||||
"@storybook/addon-viewport": "^5.3.19",
|
||||
"@storybook/addons": "^5.3.19",
|
||||
"@storybook/core": "^5.3.19",
|
||||
"@storybook/vue": "^5.3.19",
|
||||
"@types/base-64": "^0.1.3",
|
||||
"@types/bytes": "^3.1.0",
|
||||
"@types/diff": "^4.0.2",
|
||||
"@types/highlight.js": "^9.12.4",
|
||||
"@types/jest": "^26.0.5",
|
||||
"@types/marked": "^1.1.0",
|
||||
"@types/mime-types": "^2.1.0",
|
||||
"@types/qrcode": "^1.3.5",
|
||||
"@types/semver": "^7.3.1",
|
||||
"@types/webpack-env": "^1.15.2",
|
||||
"@typescript-eslint/eslint-plugin": "^2.34.0",
|
||||
"@typescript-eslint/parser": "^2.34.0",
|
||||
"@typescript-eslint/typescript-estree": "^3.7.0",
|
||||
"@vue/cli-plugin-babel": "^4.4.6",
|
||||
"@vue/cli-plugin-eslint": "^4.4.6",
|
||||
"@vue/cli-plugin-router": "^4.4.5",
|
||||
"@vue/cli-plugin-typescript": "^4.4.6",
|
||||
"@vue/cli-plugin-unit-jest": "^4.4.6",
|
||||
"@vue/cli-plugin-vuex": "^4.4.6",
|
||||
"@vue/cli-service": "^4.4.6",
|
||||
"@vue/cli-plugin-babel": "^4.5.6",
|
||||
"@vue/cli-plugin-eslint": "^4.5.6",
|
||||
"@vue/cli-plugin-router": "^4.5.6",
|
||||
"@vue/cli-plugin-typescript": "^4.5.6",
|
||||
"@vue/cli-plugin-unit-jest": "^4.5.6",
|
||||
"@vue/cli-plugin-vuex": "^4.5.6",
|
||||
"@vue/cli-service": "^4.5.6",
|
||||
"@vue/eslint-config-prettier": "^6.0.0",
|
||||
"@vue/eslint-config-typescript": "^5.0.2",
|
||||
"@vue/test-utils": "^1.0.3",
|
||||
"autoprefixer": "^9.8.5",
|
||||
"babel-loader": "^8.1.0",
|
||||
"babel-preset-vue": "^2.0.2",
|
||||
"css-loader": "^3.6.0",
|
||||
"eslint": "^6.8.0",
|
||||
"eslint-plugin-prettier": "^3.1.4",
|
||||
"eslint-plugin-vue": "^6.2.2",
|
||||
"html-loader": "^1.1.0",
|
||||
"husky": "^4.2.5",
|
||||
"jest-sonar": "^0.2.10",
|
||||
"lint-staged": "^10.2.11",
|
||||
"mockdate": "^3.0.2",
|
||||
"prettier": "^2.0.5",
|
||||
"raw-loader": "^4.0.1",
|
||||
"react": "^16.13.1",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-is": "^16.13.1",
|
||||
"regenerator-runtime": "^0.13.7",
|
||||
"sass": "^1.26.10",
|
||||
"sass-loader": "^9.0.2",
|
||||
"storybook-addon-themes": "^5.4.1",
|
||||
"stylelint": "^13.6.1",
|
||||
"stylelint-config-rational-order": "^0.1.2",
|
||||
"stylelint-config-standard": "^20.0.0",
|
||||
"stylelint-order": "^4.1.0",
|
||||
"stylelint-scss": "^3.18.0",
|
||||
"typescript": "^3.9.7",
|
||||
"vue-cli-plugin-storybook": "^1.3.0",
|
||||
"vue-loader": "^15.9.3",
|
||||
"vue-template-compiler": "^2.6.10",
|
||||
"vuepress": "^1.5.2",
|
||||
"webpack": "^4.43.0",
|
||||
"webpack-assets-manifest": "^3.1.1",
|
||||
"webpack-merge": "^5.0.9"
|
||||
},
|
||||
"husky": {
|
||||
"hooks": {
|
||||
"pre-commit": "lint-staged"
|
||||
}
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,ts}": [
|
||||
"vue-cli-service lint"
|
||||
],
|
||||
"*.{css,scss}": [
|
||||
"stylelint --fix"
|
||||
],
|
||||
"*.vue": [
|
||||
"vue-cli-service lint",
|
||||
"stylelint --fix"
|
||||
]
|
||||
},
|
||||
"gitHead": "4476da28dbbc2824e680137aa28b2b91b5afabec"
|
||||
"@vue/eslint-config-typescript": "^5.1.0",
|
||||
"@vue/test-utils": "^1.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +57,8 @@ export const onError = async (error: RequestError) => {
|
||||
status === 401 &&
|
||||
code === 'INVALID_CREDENTIALS' &&
|
||||
error.request.responseURL.includes('refresh') === false &&
|
||||
error.request.responseURL.includes('login') === false
|
||||
error.request.responseURL.includes('login') === false &&
|
||||
error.request.responseURL.includes('tfa') === false
|
||||
) {
|
||||
let newToken: string;
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref, computed } from '@vue/composition-api';
|
||||
import { nanoid } from 'nanoid';
|
||||
import useShortcut from '@/composables/use-shortcut';
|
||||
|
||||
export default defineComponent({
|
||||
model: {
|
||||
@@ -31,6 +32,14 @@ export default defineComponent({
|
||||
},
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const dialog = ref<HTMLElement | null>(null);
|
||||
useShortcut('escape', (event, cancelNext) => {
|
||||
if (_active.value) {
|
||||
emitToggle();
|
||||
cancelNext();
|
||||
}
|
||||
});
|
||||
|
||||
const localActive = ref(false);
|
||||
|
||||
const className = ref<string | null>(null);
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
/>
|
||||
<span @click="toggle">
|
||||
{{ field.name }}
|
||||
<v-icon class="required" sup name="star" v-if="field.required" />
|
||||
<v-icon class="required" sup name="star" v-if="field.schema && field.schema.is_nullable === false" />
|
||||
<v-icon v-if="!disabled" class="ctx-arrow" :class="{ active }" name="arrow_drop_down" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -135,7 +135,15 @@ export default defineComponent({
|
||||
|
||||
const rawValue = computed({
|
||||
get() {
|
||||
return _value.value;
|
||||
switch (type.value) {
|
||||
case 'object':
|
||||
return JSON.stringify(_value.value, null, '\t');
|
||||
case 'string':
|
||||
case 'number':
|
||||
case 'boolean':
|
||||
default:
|
||||
return _value.value;
|
||||
}
|
||||
},
|
||||
set(newRawValue: string) {
|
||||
switch (type.value) {
|
||||
|
||||
@@ -118,6 +118,21 @@ export default defineComponent({
|
||||
|
||||
const { formFields } = useFormFields(fields);
|
||||
|
||||
const formFieldsParsed = computed(() => {
|
||||
if (props.primaryKey === '+') return formFields.value;
|
||||
|
||||
return formFields.value.map((field: Field) => {
|
||||
if (field.schema?.is_primary_key === true) {
|
||||
const fieldClone = clone(field) as any;
|
||||
if (!fieldClone.meta) fieldClone.meta = {};
|
||||
fieldClone.meta.readonly = true;
|
||||
return fieldClone;
|
||||
}
|
||||
|
||||
return field;
|
||||
});
|
||||
});
|
||||
|
||||
const { width } = useElementSize(el);
|
||||
|
||||
const gridClass = computed<string | null>(() => {
|
||||
@@ -132,7 +147,7 @@ export default defineComponent({
|
||||
return null;
|
||||
});
|
||||
|
||||
return { formFields, gridClass, isDisabled };
|
||||
return { formFields: formFieldsParsed, gridClass, isDisabled };
|
||||
|
||||
function isDisabled(field: Field) {
|
||||
return (
|
||||
|
||||
@@ -15,7 +15,9 @@
|
||||
1
|
||||
</v-button>
|
||||
|
||||
<span v-if="showFirstLast && value > Math.ceil(totalVisible / 2) + 1 && length > (totalVisible + 1)" class="gap">...</span>
|
||||
<span v-if="showFirstLast && value > Math.ceil(totalVisible / 2) + 1 && length > totalVisible + 1" class="gap">
|
||||
...
|
||||
</span>
|
||||
|
||||
<v-button
|
||||
v-for="page in visiblePages"
|
||||
@@ -30,7 +32,10 @@
|
||||
{{ page }}
|
||||
</v-button>
|
||||
|
||||
<span v-if="showFirstLast && value < length - Math.ceil(totalVisible / 2) && length > (totalVisible + 1)" class="gap">
|
||||
<span
|
||||
v-if="showFirstLast && value < length - Math.ceil(totalVisible / 2) && length > totalVisible + 1"
|
||||
class="gap"
|
||||
>
|
||||
...
|
||||
</span>
|
||||
|
||||
@@ -139,9 +144,9 @@ body {
|
||||
display: flex;
|
||||
|
||||
.gap {
|
||||
display: none;
|
||||
margin: 0 4px;
|
||||
color: var(--foreground-subdued);
|
||||
display: none;
|
||||
line-height: 2em;
|
||||
|
||||
@include breakpoint(small) {
|
||||
|
||||
@@ -198,7 +198,7 @@ export default defineComponent({
|
||||
if (existing.value === dragHeader.value?.value) {
|
||||
return {
|
||||
...existing,
|
||||
width: Math.max(50, newWidth),
|
||||
width: Math.max(32, newWidth),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed, ref, PropType } from '@vue/composition-api';
|
||||
import { defineComponent, computed, ref, PropType, onMounted, watch } from '@vue/composition-api';
|
||||
import { Header, HeaderRaw, Item, ItemSelectEvent, Sort } from './types';
|
||||
import TableHeader from './table-header/';
|
||||
import TableRow from './table-row/';
|
||||
@@ -86,7 +86,6 @@ import { sortBy, clone, forEach, pick } from 'lodash';
|
||||
import { i18n } from '@/lang/';
|
||||
import draggable from 'vuedraggable';
|
||||
import hideDragImage from '@/utils/hide-drag-image';
|
||||
import useShortcut from '@/composables/use-shortcut';
|
||||
|
||||
const HeaderDefaults: Header = {
|
||||
text: '',
|
||||
@@ -292,10 +291,6 @@ export default defineComponent({
|
||||
return gridTemplateColumns;
|
||||
});
|
||||
|
||||
useShortcut('mod+a', () => {
|
||||
onToggleSelectAll(!allItemsSelected.value);
|
||||
});
|
||||
|
||||
return {
|
||||
_headers,
|
||||
_items,
|
||||
|
||||
@@ -159,7 +159,7 @@ export default defineComponent({
|
||||
});
|
||||
|
||||
if (uploadedFiles) {
|
||||
emit('upload', props.multiple ? uploadedFiles : uploadedFiles[0]);
|
||||
emit('input', props.multiple ? uploadedFiles : uploadedFiles[0]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
@@ -216,29 +216,17 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
function useSelection() {
|
||||
const collection = ref('directus_files');
|
||||
const image = ref<string | null>(null);
|
||||
const { item, error, loading } = useItem(collection, image);
|
||||
return { setSelection };
|
||||
|
||||
function setSelection(selection: string[]) {
|
||||
async function setSelection(selection: string[]) {
|
||||
if (selection[0]) {
|
||||
image.value = selection[0];
|
||||
const id = selection[0];
|
||||
const fileResponse = await api.get(`/files/${id}`);
|
||||
emit('input', fileResponse.data.data);
|
||||
} else {
|
||||
image.value = null;
|
||||
emit('upload', null);
|
||||
emit('input', null);
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => item.value,
|
||||
(id) => {
|
||||
if (error.value === null && loading.value === false) {
|
||||
emit('upload', item.value);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return { setSelection };
|
||||
}
|
||||
|
||||
function useURLImport() {
|
||||
@@ -265,7 +253,7 @@ export default defineComponent({
|
||||
url: url.value,
|
||||
});
|
||||
|
||||
emit('upload', response.data.data);
|
||||
emit('input', response.data.data);
|
||||
activeDialog.value = null;
|
||||
url.value = '';
|
||||
} catch (err) {
|
||||
|
||||
@@ -111,12 +111,6 @@ export function useItem(collection: Ref<string>, primaryKey: Ref<string | number
|
||||
if (isNew.value) {
|
||||
notify({
|
||||
title: i18n.tc('item_create_failed', isBatch.value ? 2 : 1),
|
||||
text: i18n.tc('item_in', isBatch.value ? 2 : 1, {
|
||||
collection: collection.value,
|
||||
primaryKey: isBatch.value
|
||||
? (primaryKey.value as string).split(',').join(', ')
|
||||
: primaryKey.value,
|
||||
}),
|
||||
type: 'error',
|
||||
});
|
||||
} else {
|
||||
@@ -138,9 +132,9 @@ export function useItem(collection: Ref<string>, primaryKey: Ref<string | number
|
||||
.map((err: APIError) => {
|
||||
return err.extensions;
|
||||
});
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
|
||||
throw err;
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import { useShortcut } from '@/composables/use-shortcut';
|
||||
|
||||
export default defineComponent({
|
||||
setup(props) {
|
||||
useShortcut('mod+s', save);
|
||||
useShortcut('meta+s', save);
|
||||
|
||||
function save() {
|
||||
// ...
|
||||
|
||||
@@ -1,27 +1,106 @@
|
||||
import { onMounted, onUnmounted } from '@vue/composition-api';
|
||||
import Mousetrap, { ExtendedKeyboardEvent } from 'mousetrap';
|
||||
import { onMounted, onUnmounted, Ref, ref } from '@vue/composition-api';
|
||||
import Vue from 'vue';
|
||||
|
||||
const mousetrap = new Mousetrap();
|
||||
mousetrap.stopCallback = function (e: Event, element: Element) {
|
||||
// if the element has the class "mousetrap" then no need to stop
|
||||
if (element.hasAttribute('data-disable-mousetrap')) {
|
||||
return true;
|
||||
}
|
||||
type ShortcutHandler = (event: KeyboardEvent, cancelNext: () => void) => void | any | boolean;
|
||||
|
||||
return false;
|
||||
};
|
||||
const keysdown: Set<string> = new Set([]);
|
||||
const handlers: Record<string, ShortcutHandler[]> = {};
|
||||
|
||||
document.body.addEventListener('keydown', (event: KeyboardEvent) => {
|
||||
if (event.repeat || !event.key) return;
|
||||
|
||||
keysdown.add(mapKeys(event.key));
|
||||
callHandlers(event);
|
||||
});
|
||||
|
||||
document.body.addEventListener('keyup', (event: KeyboardEvent) => {
|
||||
if (event.repeat || !event.key) return;
|
||||
|
||||
const key = mapKeys(event.key);
|
||||
keysdown.delete(key.toLowerCase());
|
||||
keysdown.delete(key.toUpperCase());
|
||||
});
|
||||
|
||||
export default function useShortcut(
|
||||
shortcut: string | string[],
|
||||
handler: (evt?: ExtendedKeyboardEvent, combo?: string) => void
|
||||
shortcuts: string | string[],
|
||||
handler: ShortcutHandler,
|
||||
reference: Ref<HTMLElement | undefined> | Ref<Vue | undefined> = ref(document.body)
|
||||
) {
|
||||
const callback: ShortcutHandler = (event, cancelNext) => {
|
||||
if (!reference.value) return;
|
||||
const ref = reference.value instanceof HTMLElement ? reference.value : (reference.value.$el as HTMLElement);
|
||||
|
||||
if (
|
||||
document.activeElement === ref ||
|
||||
ref.contains(document.activeElement) ||
|
||||
document.activeElement === document.body
|
||||
) {
|
||||
event.preventDefault();
|
||||
return handler(event, cancelNext);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
mousetrap.bind(shortcut, (e, combo) => {
|
||||
e.preventDefault();
|
||||
handler(e, combo);
|
||||
[shortcuts].flat().forEach((shortcut) => {
|
||||
if (handlers.hasOwnProperty(shortcut)) {
|
||||
handlers[shortcut].unshift(callback);
|
||||
} else {
|
||||
handlers[shortcut] = [callback];
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
mousetrap.unbind(shortcut);
|
||||
[shortcuts].flat().forEach((shortcut) => {
|
||||
if (handlers.hasOwnProperty(shortcut)) {
|
||||
handlers[shortcut] = handlers[shortcut].filter((f) => f !== callback);
|
||||
|
||||
if (handlers[shortcut].length === 0) {
|
||||
delete handlers[shortcut];
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function mapKeys(key: string) {
|
||||
const map: Record<string, string> = {
|
||||
Control: 'meta',
|
||||
Command: 'meta',
|
||||
};
|
||||
|
||||
key = map.hasOwnProperty(key) ? map[key] : key;
|
||||
|
||||
if (key.match(/^[a-z]$/) !== null) {
|
||||
if (keysdown.has('shift')) key = key.toUpperCase();
|
||||
} else if (key.match(/^[A-Z]$/) !== null) {
|
||||
if (keysdown.has('shift')) key = key.toLowerCase();
|
||||
} else {
|
||||
key = key.toLowerCase();
|
||||
}
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
function callHandlers(event: KeyboardEvent) {
|
||||
Object.entries(handlers).forEach(([key, value]) => {
|
||||
const rest = key.split('+').filter((keySegment) => keysdown.has(keySegment) === false);
|
||||
|
||||
if (rest.length > 0) return;
|
||||
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
let cancel = false;
|
||||
|
||||
value[i](event, cancelNext);
|
||||
|
||||
// if cancelNext is called, discontinue going through the queue.
|
||||
if (typeof cancel === 'boolean' && cancel) break;
|
||||
|
||||
function cancelNext() {
|
||||
cancel = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ export default defineComponent({
|
||||
},
|
||||
showAsDot: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
default: false,
|
||||
},
|
||||
choices: {
|
||||
type: Array as PropType<Choice[]>,
|
||||
@@ -78,7 +78,7 @@ export default defineComponent({
|
||||
else items = props.value as string[];
|
||||
|
||||
return items.map((item) => {
|
||||
const choice = props.choices.find((choice) => choice.value === item);
|
||||
const choice = (props.choices || []).find((choice) => choice.value === item);
|
||||
|
||||
if (choice === undefined) {
|
||||
return {
|
||||
|
||||
@@ -10,8 +10,24 @@
|
||||
|
||||
<v-dialog persistent v-model="enableActive">
|
||||
<v-card>
|
||||
<v-progress-circular class="loader" indeterminate v-if="loading === true" />
|
||||
<template v-show="loading === false">
|
||||
<template v-if="tfaEnabled === false" v-show="loading === false">
|
||||
<v-card-title>
|
||||
{{ $t('enter_password_to_enable_tfa') }}
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<v-input v-model="password" type="password" :placeholder="$t('password')" />
|
||||
|
||||
<v-error v-if="error" :error="error" />
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-button @click="enableActive = false" secondary>{{ $t('cancel') }}</v-button>
|
||||
<v-button @click="enableTFA" :loading="loading">{{ $t('next') }}</v-button>
|
||||
</v-card-actions>
|
||||
</template>
|
||||
|
||||
<v-progress-circular class="loader" indeterminate v-else-if="loading === true" />
|
||||
|
||||
<div v-show="tfaEnabled === true && loading === false">
|
||||
<v-card-title>
|
||||
{{ $t('tfa_scan_code') }}
|
||||
</v-card-title>
|
||||
@@ -22,7 +38,7 @@
|
||||
<v-card-actions>
|
||||
<v-button @click="enableActive = false">{{ $t('done') }}</v-button>
|
||||
</v-card-actions>
|
||||
</template>
|
||||
</div>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
@@ -45,7 +61,7 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref, watch } from '@vue/composition-api';
|
||||
import { defineComponent, ref, watch, onMounted } from '@vue/composition-api';
|
||||
import api from '@/api';
|
||||
import qrcode from 'qrcode';
|
||||
import { nanoid } from 'nanoid';
|
||||
@@ -66,6 +82,11 @@ export default defineComponent({
|
||||
const secret = ref<string>();
|
||||
const otp = ref('');
|
||||
const error = ref<any>();
|
||||
const password = ref('');
|
||||
|
||||
onMounted(() => {
|
||||
password.value = '';
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.value,
|
||||
@@ -75,25 +96,46 @@ export default defineComponent({
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
return { tfaEnabled, toggle, enableActive, disableActive, loading, canvasID, secret, disableTFA, otp, error };
|
||||
return {
|
||||
tfaEnabled,
|
||||
enableTFA,
|
||||
toggle,
|
||||
password,
|
||||
enableActive,
|
||||
disableActive,
|
||||
loading,
|
||||
canvasID,
|
||||
secret,
|
||||
disableTFA,
|
||||
otp,
|
||||
error,
|
||||
};
|
||||
|
||||
function toggle() {
|
||||
if (tfaEnabled.value === false) {
|
||||
enableActive.value = true;
|
||||
enableTFA();
|
||||
} else {
|
||||
disableActive.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function enableTFA() {
|
||||
if (loading.value === true) return;
|
||||
|
||||
loading.value = true;
|
||||
const response = await api.post('/users/me/tfa/enable');
|
||||
const url = response.data.data.otpauth_url;
|
||||
secret.value = response.data.data.secret;
|
||||
await qrcode.toCanvas(document.getElementById(canvasID), url);
|
||||
loading.value = false;
|
||||
tfaEnabled.value = true;
|
||||
|
||||
try {
|
||||
const response = await api.post('/users/me/tfa/enable', { password: password.value });
|
||||
const url = response.data.data.otpauth_url;
|
||||
secret.value = response.data.data.secret;
|
||||
await qrcode.toCanvas(document.getElementById(canvasID), url);
|
||||
tfaEnabled.value = true;
|
||||
error.value = null;
|
||||
} catch (err) {
|
||||
error.value = err;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function disableTFA() {
|
||||
@@ -145,4 +187,8 @@ export default defineComponent({
|
||||
--v-button-background-color: var(--warning);
|
||||
--v-button-background-color-hover: var(--warning-125);
|
||||
}
|
||||
|
||||
.v-error {
|
||||
margin-top: 24px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
<v-card>
|
||||
<v-card-title>{{ $t('upload_from_device') }}</v-card-title>
|
||||
<v-card-text>
|
||||
<v-upload @upload="onUpload" from-url />
|
||||
<v-upload @input="onUpload" from-url />
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-button @click="activeDialog = null" secondary>{{ $t('cancel') }}</v-button>
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
<v-dialog v-model="showUpload">
|
||||
<v-card>
|
||||
<v-card-title>{{ $t('upload_file') }}</v-card-title>
|
||||
<v-card-text><v-upload @upload="onUpload" multiple from-url /></v-card-text>
|
||||
<v-card-text><v-upload @input="onUpload" multiple from-url /></v-card-text>
|
||||
<v-card-actions>
|
||||
<v-button @click="showUpload = false">{{ $t('done') }}</v-button>
|
||||
</v-card-actions>
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
/>
|
||||
<file-lightbox v-model="lightboxActive" :id="image.id" />
|
||||
</div>
|
||||
<v-upload v-else @upload="setImage" from-library from-url />
|
||||
<v-upload v-else @input="setImage" from-library from-url />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { Ref } from '@vue/composition-api';
|
||||
import { Relation } from '@/types/';
|
||||
import { Field } from '@/types';
|
||||
|
||||
type IsNewContext = {
|
||||
relationCurrentToJunction: Ref<Relation | undefined>;
|
||||
junctionCollectionPrimaryKeyField: Ref<Field>;
|
||||
relatedCollectionPrimaryKeyField: Ref<Field>;
|
||||
};
|
||||
|
||||
export default function isNew(
|
||||
item: any,
|
||||
{ relationCurrentToJunction, junctionCollectionPrimaryKeyField, relatedCollectionPrimaryKeyField }: IsNewContext
|
||||
) {
|
||||
if (!relationCurrentToJunction.value) return;
|
||||
if (!relationCurrentToJunction.value.junction_field) return;
|
||||
|
||||
const junctionPrimaryKey = junctionCollectionPrimaryKeyField.value.field;
|
||||
const junctionField = relationCurrentToJunction.value.junction_field;
|
||||
const relatedPrimaryKey = relatedCollectionPrimaryKeyField.value.field;
|
||||
const hasPrimaryKey = !!item[junctionPrimaryKey];
|
||||
const hasRelatedPrimaryKey = !!item[junctionField]?.[relatedPrimaryKey];
|
||||
|
||||
return hasPrimaryKey === false && hasRelatedPrimaryKey === false;
|
||||
}
|
||||
@@ -16,7 +16,7 @@
|
||||
<template v-for="header in tableHeaders" v-slot:[`item.${header.value}`]="{ item }">
|
||||
<render-display
|
||||
:key="header.value"
|
||||
:value="item[header.value]"
|
||||
:value="get(item, header.value)"
|
||||
:display="header.field.display"
|
||||
:options="header.field.displayOptions"
|
||||
:interface="header.field.interface"
|
||||
@@ -41,7 +41,7 @@
|
||||
|
||||
<modal-detail
|
||||
v-if="!disabled"
|
||||
:active="showDetailModal"
|
||||
:active.sync="showDetailModal"
|
||||
:collection="junctionCollection"
|
||||
:primary-key="junctionRowPrimaryKey"
|
||||
:edits="editsAtStart"
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { ref, Ref } from '@vue/composition-api';
|
||||
import { Field, Relation } from '@/types';
|
||||
import isNew from './is-new';
|
||||
import { set } from 'lodash';
|
||||
|
||||
type EditParam = {
|
||||
@@ -28,6 +27,7 @@ export default function useEdit({
|
||||
const junctionRowPrimaryKey = ref<number | string>('+');
|
||||
const relatedRowPrimaryKey = ref<number | string>('+');
|
||||
const initialValues = ref<any>(null);
|
||||
const isNew = ref(false);
|
||||
|
||||
return {
|
||||
showDetailModal,
|
||||
@@ -47,6 +47,7 @@ export default function useEdit({
|
||||
junctionRowPrimaryKey.value = '+';
|
||||
relatedRowPrimaryKey.value = '+';
|
||||
initialValues.value = null;
|
||||
isNew.value = true;
|
||||
}
|
||||
|
||||
// The row here is the item in previewItems that's passed to the table
|
||||
@@ -54,13 +55,9 @@ export default function useEdit({
|
||||
if (!relationCurrentToJunction.value) return;
|
||||
if (!relationCurrentToJunction.value.junction_field) return;
|
||||
|
||||
if (
|
||||
isNew(item, {
|
||||
relationCurrentToJunction,
|
||||
junctionCollectionPrimaryKeyField,
|
||||
relatedCollectionPrimaryKeyField,
|
||||
})
|
||||
) {
|
||||
if (item.$new === true) isNew.value = true;
|
||||
|
||||
if (isNew.value === true) {
|
||||
editsAtStart.value = item;
|
||||
junctionRowPrimaryKey.value = '+';
|
||||
showDetailModal.value = true;
|
||||
@@ -81,7 +78,7 @@ export default function useEdit({
|
||||
|
||||
junctionRowPrimaryKey.value = item[junctionPrimaryKey] || '+';
|
||||
relatedRowPrimaryKey.value = item[junctionField]?.[relatedPrimaryKey] || '+';
|
||||
editsAtStart.value = item['$stagedEdits'] || null;
|
||||
editsAtStart.value = item.$stagedEdits || null;
|
||||
showDetailModal.value = true;
|
||||
}
|
||||
|
||||
@@ -99,6 +96,10 @@ export default function useEdit({
|
||||
const junctionField = relationCurrentToJunction.value.junction_field;
|
||||
const relatedPrimaryKey = relatedCollectionPrimaryKeyField.value.field;
|
||||
|
||||
if (isNew.value) {
|
||||
edits.$new = true;
|
||||
}
|
||||
|
||||
const currentValue = [...(value.value || [])];
|
||||
|
||||
// If there weren't any previously made edits, it's safe to assume this change value
|
||||
@@ -134,6 +135,7 @@ export default function useEdit({
|
||||
showDetailModal.value = true;
|
||||
junctionRowPrimaryKey.value = '+';
|
||||
relatedRowPrimaryKey.value = '+';
|
||||
isNew.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import api from '@/api';
|
||||
import { Field, Relation } from '@/types';
|
||||
import { merge } from 'lodash';
|
||||
import adjustFieldsForDisplay from '@/utils/adjust-fields-for-displays';
|
||||
import isNew from './is-new';
|
||||
|
||||
/**
|
||||
* Controls what preview is shown in the table. Has some black magic logic to ensure we're able
|
||||
@@ -58,6 +57,7 @@ export default function usePreview({
|
||||
);
|
||||
} catch (err) {
|
||||
error.value = err;
|
||||
throw err;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -159,15 +159,9 @@ export default function usePreview({
|
||||
* for me for now to worry about..
|
||||
*/
|
||||
|
||||
return (value.value || [])
|
||||
.filter((stagedEdit: any) => !stagedEdit['$delete'])
|
||||
.filter((item) =>
|
||||
isNew(item, {
|
||||
relationCurrentToJunction,
|
||||
junctionCollectionPrimaryKeyField,
|
||||
relatedCollectionPrimaryKeyField,
|
||||
})
|
||||
);
|
||||
const junctionPrimaryKey = junctionCollectionPrimaryKeyField.value.field;
|
||||
|
||||
return (value.value || []).filter((stagedEdit: any) => !stagedEdit.$delete && !stagedEdit[junctionPrimaryKey]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -187,14 +181,9 @@ export default function usePreview({
|
||||
const junctionField = relationCurrentToJunction.value.junction_field;
|
||||
const relatedPrimaryKey = relatedCollectionPrimaryKeyField.value.field;
|
||||
|
||||
const newlySelectedStagedItems = (value.value || [])
|
||||
.filter((stagedEdit: any) => !stagedEdit['$delete'])
|
||||
.filter((stagedEdit: any) => {
|
||||
return (
|
||||
stagedEdit[junctionPrimaryKey] === undefined &&
|
||||
stagedEdit[junctionField]?.[relatedPrimaryKey] !== undefined
|
||||
);
|
||||
});
|
||||
const newlySelectedStagedItems = (value.value || []).filter(
|
||||
(stagedEdit: any) => !stagedEdit.$delete && !stagedEdit[junctionPrimaryKey] && !stagedEdit.$new
|
||||
);
|
||||
|
||||
const newlySelectedRelatedKeys = newlySelectedStagedItems.map(
|
||||
(stagedEdit: any) => stagedEdit[junctionField][relatedPrimaryKey]
|
||||
|
||||
@@ -215,7 +215,7 @@ export default defineComponent({
|
||||
},
|
||||
});
|
||||
|
||||
items.value = response.data.data[props.field];
|
||||
items.value = response.data.data[props.field] || [];
|
||||
} catch (err) {
|
||||
error.value = err;
|
||||
} finally {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user