Merge branch 'main' into v-list-subdued

This commit is contained in:
Nitwel
2020-10-05 17:08:49 +02:00
187 changed files with 33122 additions and 63230 deletions

View File

@@ -9,8 +9,7 @@ inputs:
required: true
registry:
description: "Registry"
required: false
default: ghcr.io
required: true
username:
description: "Registry user"
required: true

View File

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

View File

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

View File

@@ -24,6 +24,7 @@ jobs:
- name: Build Docker Hub
uses: ./.github/actions/build-images
with:
registry: "docker.io"
repository: "${{ github.repository }}"
username: "${{ secrets.DOCKERHUB_USERNAME }}"
password: "${{ secrets.DOCKERHUB_PASSWORD }}"

4
.gitignore vendored
View File

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

1
.npmrc
View File

@@ -1 +0,0 @@
package-lock=false

5
api/cli.js Executable file
View File

@@ -0,0 +1,5 @@
#!/usr/bin/env node
try {
return require('./dist/cli/index.js');
} catch {}

View File

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

32829
api/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "directus",
"version": "9.0.0-beta.4",
"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.4",
"@directus/app": "file:../app",
"@directus/format-title": "^3.2.0",
"@slynova/flydrive": "^1.0.2",
"@slynova/flydrive-gcs": "^1.0.2",
@@ -86,10 +86,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",
@@ -127,51 +129,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"
}
}

View File

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

View File

@@ -1,7 +1,7 @@
####################################################################################################
## General
PORT=41201
PORT=8055
PUBLIC_URL="/"
####################################################################################################

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,15 +1,20 @@
import { Router } from 'express';
import { ServerService } from '../services';
import { respond } from '../middleware/respond';
const router = Router();
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;

View File

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

View File

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

View File

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

View File

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

View File

@@ -21,7 +21,7 @@ columns:
type: boolean
nullable: false
default: false
translation:
translations:
type: json
archive_field:
type: string

View File

@@ -52,7 +52,7 @@ columns:
references:
table: directus_fields
column: id
translation:
translations:
type: json
note:
type: text

View File

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

View File

@@ -9,7 +9,7 @@ columns:
type: string
length: 255
nullable: false
parent_folder:
parent:
type: uuid
references:
table: directus_folders

View File

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

View File

@@ -38,19 +38,19 @@ 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

View File

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

View File

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

View File

@@ -33,7 +33,7 @@ fields:
locked: true
special: csv
- collection: directus_fields
field: translation
field: translations
hidden: true
locked: true
special: json

View File

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

View File

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

View File

@@ -11,5 +11,5 @@ hidden: false
sort: null
width: full
group: null
translation: null
translations: null
note: null

View File

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

View File

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

View File

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

View File

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

View File

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

389
api/src/services/graphql.ts Normal file
View 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;
}
}

View File

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

View File

@@ -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;
}
@@ -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[],
@@ -329,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,
@@ -341,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);
}
@@ -357,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),
}));
@@ -440,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,
@@ -474,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);

View File

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

View File

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

View File

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

View File

@@ -34,7 +34,7 @@ export type FieldMeta = {
width: string | null;
group: number | null;
note: string | null;
translation: null;
translations: null;
};
export type Field = {

View File

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

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

View File

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

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

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

View File

@@ -1,17 +0,0 @@
import Joi from 'joi';
const schema = Joi.alternatives().try(
Joi.object({
name: Joi.string().required(),
age: Joi.number()
}),
Joi.string(),
).match('all');
const value = {
age: 25
};
const { error } = schema.validate(value);
console.log(JSON.stringify(error, null, 2));

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "@directus/app",
"version": "9.0.0-beta.4",
"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.4",
"@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"
}
}

View File

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

View File

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

View File

@@ -52,6 +52,9 @@
:disabled="item.disabled"
@click="multiple ? null : $emit('input', item.value)"
>
<v-list-item-icon v-if="multiple === false && allowOther === false && itemIcon !== null && item.icon">
<v-icon :name="item.icon" />
</v-list-item-icon>
<v-list-item-content>
<span v-if="multiple === false" class="item-text">{{ item.text }}</span>
<v-checkbox
@@ -142,6 +145,10 @@ export default defineComponent({
type: String,
default: 'value',
},
itemIcon: {
type: String,
default: null,
},
value: {
type: [Array, String, Number] as PropType<InputValue>,
default: null,
@@ -215,6 +222,7 @@ export default defineComponent({
return {
text: item[props.itemText],
value: item[props.itemValue],
icon: item[props.itemIcon],
disabled: item.disabled,
};
});

View File

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

View File

@@ -59,8 +59,8 @@
"action": "Action",
"collection": "Collection",
"item": "Item Primary Key",
"action_by": "Action By",
"action_on": "Action On",
"user": "Action By",
"timestamp": "Action On",
"edited_on": "Edited On",
"comment_deleted_on": "Comment Deleted On",
"ip": "IP Address",

View File

@@ -59,8 +59,8 @@
"action": "Action",
"collection": "Collection",
"item": "Item Primary Key",
"action_by": "Action By",
"action_on": "Action On",
"user": "Action By",
"timestamp": "Action On",
"edited_on": "Edited On",
"comment_deleted_on": "Comment Deleted On",
"ip": "IP Address",

View File

@@ -59,8 +59,8 @@
"action": "Action",
"collection": "Collection",
"item": "Item Primary Key",
"action_by": "Action By",
"action_on": "Action On",
"user": "Action By",
"timestamp": "Action On",
"edited_on": "Edited On",
"comment_deleted_on": "Comment Deleted On",
"ip": "IP Address",

View File

@@ -59,8 +59,8 @@
"action": "Akce",
"collection": "Kategorie",
"item": "Primární klíč položky",
"action_by": "Action By",
"action_on": "Action On",
"user": "Action By",
"timestamp": "Action On",
"edited_on": "Edited On",
"comment_deleted_on": "Comment Deleted On",
"ip": "IP adresa",

View File

@@ -59,8 +59,8 @@
"action": "Action",
"collection": "Collection",
"item": "Item Primary Key",
"action_by": "Action By",
"action_on": "Action On",
"user": "Action By",
"timestamp": "Action On",
"edited_on": "Edited On",
"comment_deleted_on": "Comment Deleted On",
"ip": "IP Address",

View File

@@ -59,8 +59,8 @@
"action": "Aktion",
"collection": "Sammlung",
"item": "Item-Primärschlüssel",
"action_by": "Aktion von",
"action_on": "Aktion am",
"user": "Aktion von",
"timestamp": "Aktion am",
"edited_on": "Bearbeitet am",
"comment_deleted_on": "Kommentar gelöscht am",
"ip": "IP Adresse",

View File

@@ -59,8 +59,8 @@
"action": "Ενέργεια",
"collection": "Συλλογή",
"item": "Στοιχείο Πρωτεύον Κλειδί",
"action_by": "Ενέργεια από",
"action_on": "Ενέργεια στις",
"user": "Ενέργεια από",
"timestamp": "Ενέργεια στις",
"edited_on": "Επεξεργασμένο στις",
"comment_deleted_on": "Το σχόλιο διεγράφη στις",
"ip": "Διεύθυνση IP",

View File

@@ -10,6 +10,8 @@
"only_show_the_file_extension": "Only show the file extension",
"textarea": "Textarea",
"enter_password_to_enable_tfa": "Enter your password to enable Two-Factor Authentication",
"add_field": "Add Field",
"role_name": "Role Name",
@@ -980,8 +982,8 @@
"action": "Action",
"collection": "Collection",
"item": "Item Primary Key",
"action_by": "Action By",
"action_on": "Action On",
"user": "Action By",
"timestamp": "Action On",
"edited_on": "Edited On",
"comment_deleted_on": "Comment Deleted On",
"ip": "IP Address",
@@ -1055,7 +1057,7 @@
"enforce_tfa": "Require 2FA",
"users": "Users in Role",
"module_list": "Module Navigation",
"collection_list": "Collections Navigation"
"collection_list": "Collection Navigation"
}
},

View File

@@ -59,8 +59,8 @@
"action": "Acción",
"collection": "Colección",
"item": "Clave Primaria del Item",
"action_by": "Realizado por",
"action_on": "Realizado en",
"user": "Realizado por",
"timestamp": "Realizado en",
"edited_on": "Editado el",
"comment_deleted_on": "Comentario eliminado el",
"ip": "IP Address",

View File

@@ -59,8 +59,8 @@
"action": "Acción",
"collection": "Colección",
"item": "Clave Primaria del Item",
"action_by": "Realizado por",
"action_on": "Realizado en",
"user": "Realizado por",
"timestamp": "Realizado en",
"edited_on": "Editado el",
"comment_deleted_on": "Comentario eliminado el",
"ip": "IP Address",

View File

@@ -59,8 +59,8 @@
"action": "Acción",
"collection": "Colección",
"item": "Clave Primaria del Item",
"action_by": "Realizado por",
"action_on": "Realizado en",
"user": "Realizado por",
"timestamp": "Realizado en",
"edited_on": "Editado el",
"comment_deleted_on": "Comentario eliminado el",
"ip": "IP Address",

View File

@@ -59,8 +59,8 @@
"action": "Action",
"collection": "Collection",
"item": "Item Primary Key",
"action_by": "Action By",
"action_on": "Action On",
"user": "Action By",
"timestamp": "Action On",
"edited_on": "Edited On",
"comment_deleted_on": "Comment Deleted On",
"ip": "IP Address",

View File

@@ -59,8 +59,8 @@
"action": "Action",
"collection": "Collection",
"item": "Clé principale de l'élément",
"action_by": "Action par",
"action_on": "Action quand",
"user": "Action par",
"timestamp": "Action quand",
"edited_on": "Modifié le",
"comment_deleted_on": "Commentaire supprimé le",
"ip": "Adresse IP",

View File

@@ -59,8 +59,8 @@
"action": "Action",
"collection": "Collection",
"item": "Item Primary Key",
"action_by": "Action By",
"action_on": "Action On",
"user": "Action By",
"timestamp": "Action On",
"edited_on": "Edited On",
"comment_deleted_on": "Comment Deleted On",
"ip": "IP Address",

View File

@@ -59,8 +59,8 @@
"action": "Action",
"collection": "Collection",
"item": "Item Primary Key",
"action_by": "Action By",
"action_on": "Action On",
"user": "Action By",
"timestamp": "Action On",
"edited_on": "Edited On",
"comment_deleted_on": "Comment Deleted On",
"ip": "IP Address",

View File

@@ -59,8 +59,8 @@
"action": "Action",
"collection": "Collection",
"item": "Item Primary Key",
"action_by": "Action By",
"action_on": "Action On",
"user": "Action By",
"timestamp": "Action On",
"edited_on": "Edited On",
"comment_deleted_on": "Comment Deleted On",
"ip": "IP Address",

View File

@@ -59,8 +59,8 @@
"action": "Action",
"collection": "Collection",
"item": "Item Primary Key",
"action_by": "Action By",
"action_on": "Action On",
"user": "Action By",
"timestamp": "Action On",
"edited_on": "Edited On",
"comment_deleted_on": "Comment Deleted On",
"ip": "IP Address",

View File

@@ -59,8 +59,8 @@
"action": "Azioni",
"collection": "Collezione",
"item": "Chiave primaria elemento",
"action_by": "Azione di",
"action_on": "Azione il",
"user": "Azione di",
"timestamp": "Azione il",
"edited_on": "Modificato il",
"comment_deleted_on": "Commento eliminato il",
"ip": "Indirizzo IP",

View File

@@ -59,8 +59,8 @@
"action": "Action",
"collection": "Collection",
"item": "Item Primary Key",
"action_by": "Action By",
"action_on": "Action On",
"user": "Action By",
"timestamp": "Action On",
"edited_on": "Edited On",
"comment_deleted_on": "Comment Deleted On",
"ip": "IP Address",

View File

@@ -59,8 +59,8 @@
"action": "Action",
"collection": "Collection",
"item": "Item Primary Key",
"action_by": "Action By",
"action_on": "Action On",
"user": "Action By",
"timestamp": "Action On",
"edited_on": "Edited On",
"comment_deleted_on": "Comment Deleted On",
"ip": "IP Address",

View File

@@ -59,8 +59,8 @@
"action": "Veiksmas",
"collection": "Rinkinys",
"item": "Įrašo Pirminis Raktas",
"action_by": "Action By",
"action_on": "Action On",
"user": "Action By",
"timestamp": "Action On",
"edited_on": "Edited On",
"comment_deleted_on": "Comment Deleted On",
"ip": "IP Address",

View File

@@ -59,8 +59,8 @@
"action": "Action",
"collection": "Collection",
"item": "Item Primary Key",
"action_by": "Action By",
"action_on": "Action On",
"user": "Action By",
"timestamp": "Action On",
"edited_on": "Edited On",
"comment_deleted_on": "Comment Deleted On",
"ip": "IP Address",

View File

@@ -61,8 +61,8 @@
"action": "Actie",
"collection": "Collectie",
"item": "Item Primary Key",
"action_by": "Actie door",
"action_on": "Actie op",
"user": "Actie door",
"timestamp": "Actie op",
"edited_on": "Aangepast op",
"comment_deleted_on": "Reactie verwijderd op",
"ip": "IP Adres",

View File

@@ -59,8 +59,8 @@
"action": "Action",
"collection": "Collection",
"item": "Item Primary Key",
"action_by": "Action By",
"action_on": "Action On",
"user": "Action By",
"timestamp": "Action On",
"edited_on": "Edited On",
"comment_deleted_on": "Comment Deleted On",
"ip": "IP Address",

View File

@@ -59,8 +59,8 @@
"action": "Czynność",
"collection": "Kolekcja",
"item": "Klucz główny produktu",
"action_by": "Czynność przez",
"action_on": "Czynność na",
"user": "Czynność przez",
"timestamp": "Czynność na",
"edited_on": "Edytowano",
"comment_deleted_on": "Komentarz usunięty",
"ip": "Adres IP",

View File

@@ -59,8 +59,8 @@
"action": "Ação",
"collection": "Coleção",
"item": "Chave Primária do Item",
"action_by": "Ação Por",
"action_on": "Ação Em",
"user": "Ação Por",
"timestamp": "Ação Em",
"edited_on": "Editado Em",
"comment_deleted_on": "Comentário Excluído Em",
"ip": "Endereço de IP",

Some files were not shown because too many files have changed in this diff Show More