Add ability to share items with people outside the platform (#10663)

* Add directus_shares

* Don't check for usage limit on refresh

* Add all endpoints to the shares controller

* Move route `/auth/shared` to `/shared/auth`

* Add password protection

* Add `share` action in permissions

* Add `shares/:pk/info`

* Start on shared-view

* Add basic styling for full shared view

* Fixed migrations

* Add inline style for shared view

* Allow title override

* Finish /info endpoint for shares

* Add basic UUID validation to share/info endpont

* Add UUID validation to other routes

* Add not found state

* Cleanup /extract/finish share login endpoint

* Cleanup auth

* Added `share_start` and `share_end`

* Add share sidebar details.

* Allow share permissions configuration

* Hide the `new_share` button for unauthorized users

* Fix uses_left displayed value

* Show expired / upcoming shares

* Improved expired/upcoming styling

* Fixed share login query

* Fix check-ip and get-permissions middlewares behaviour when role is null

* Simplify cache key

* Fix typescript linting issues

* Handle app auth flow for shared page

* Fixed /users/me response

* Show when user is authenticated

* Try showing item drawer in shared page

* Improved shared card styling

* Add shares permissions and change share card styling

* Pull in schema/permissions on share

* Create getPermissionForShare file

* Change getPermissionsForShare signature

* Render form + item on share after auth

* Finalize public front end

* Handle fake o2m field in applyQuery

* [WIP]

* New translations en-US.yaml (Bulgarian) (#10585)

* smaller label height (#10587)

* Update to the latest Material Icons (#10573)

The icons are based on https://fonts.google.com/icons

* New translations en-US.yaml (Arabic) (#10593)

* New translations en-US.yaml (Arabic) (#10594)

* New translations en-US.yaml (Portuguese, Brazilian) (#10604)

* New translations en-US.yaml (French) (#10605)

* New translations en-US.yaml (Italian) (#10613)

* fix M2A list not updating (#10617)

* Fix filters

* Add admin filter on m2o role selection

* Add admin filter on m2o role selection

* Add o2m permissions traversing

* Finish relational tree permissions generation

* Handle implicit a2o relation

* Update implicit relation regex

* Fix regex

* Fix implicitRelation unnesting for new regex

* Fix implicitRelation length check

* Rename m2a to a2o internally

* Add auto-gen permissions for a2o

* [WIP] Improve share UX

* Add ctx menu options

* Add share dialog

* Add email notifications

* Tweak endpoint

* Tweak file interface disabled state

* Add nicer invalid state to password input

* Dont return info for expired/upcoming shares

* Tweak disabled state for relational interfaces

* Fix share button for non admin roles

* Show/hide edit/delete based on permissions to shares

* Fix imports of mutationtype

* Resolve (my own) suggestions

* Fix migration for ms sql

* Resolve last suggestion

Co-authored-by: Oreilles <oreilles.github@nitoref.io>
Co-authored-by: Oreilles <33065839+oreilles@users.noreply.github.com>
Co-authored-by: Ben Haynes <ben@rngr.org>
Co-authored-by: Thien Nguyen <72242664+tatthien@users.noreply.github.com>
Co-authored-by: Azri Kahar <42867097+azrikahar@users.noreply.github.com>
This commit is contained in:
Rijk van Zanten
2021-12-23 18:51:59 -05:00
committed by GitHub
parent d947c4f962
commit dbf35a1736
89 changed files with 2422 additions and 376 deletions

View File

@@ -55,6 +55,7 @@ const readHandler = asyncHandler(async (req, res, next) => {
accountability: req.accountability,
schema: req.schema,
});
const metaService = new MetaService({
accountability: req.accountability,
schema: req.schema,

View File

@@ -0,0 +1,287 @@
import express from 'express';
import { ForbiddenException, InvalidPayloadException } from '../exceptions';
import { respond } from '../middleware/respond';
import useCollection from '../middleware/use-collection';
import { validateBatch } from '../middleware/validate-batch';
import { SharesService } from '../services';
import { PrimaryKey } from '../types';
import asyncHandler from '../utils/async-handler';
import { UUID_REGEX, COOKIE_OPTIONS } from '../constants';
import Joi from 'joi';
import env from '../env';
const router = express.Router();
router.use(useCollection('directus_shares'));
const sharedLoginSchema = Joi.object({
share: Joi.string().required(),
password: Joi.string(),
}).unknown();
router.post(
'/auth',
asyncHandler(async (req, res, next) => {
// This doesn't use accountability, as the user isn't logged in at this point
const service = new SharesService({
schema: req.schema,
});
const { error } = sharedLoginSchema.validate(req.body);
if (error) {
throw new InvalidPayloadException(error.message);
}
const { accessToken, refreshToken, expires } = await service.login(req.body);
res.cookie(env.REFRESH_TOKEN_COOKIE_NAME, refreshToken, COOKIE_OPTIONS);
res.locals.payload = { data: { access_token: accessToken, expires } };
return next();
}),
respond
);
const sharedInviteSchema = Joi.object({
share: Joi.string().required(),
emails: Joi.array().items(Joi.string()),
}).unknown();
router.post(
'/invite',
asyncHandler(async (req, res, next) => {
const service = new SharesService({
schema: req.schema,
accountability: req.accountability,
});
const { error } = sharedInviteSchema.validate(req.body);
if (error) {
throw new InvalidPayloadException(error.message);
}
await service.invite(req.body);
return next();
}),
respond
);
router.post(
'/',
asyncHandler(async (req, res, next) => {
const service = new SharesService({
accountability: req.accountability,
schema: req.schema,
});
const savedKeys: PrimaryKey[] = [];
if (Array.isArray(req.body)) {
const keys = await service.createMany(req.body);
savedKeys.push(...keys);
} else {
const key = await service.createOne(req.body);
savedKeys.push(key);
}
try {
if (Array.isArray(req.body)) {
const items = await service.readMany(savedKeys, req.sanitizedQuery);
res.locals.payload = { data: items };
} else {
const item = await service.readOne(savedKeys[0], req.sanitizedQuery);
res.locals.payload = { data: item };
}
} catch (error) {
if (error instanceof ForbiddenException) {
return next();
}
throw error;
}
return next();
}),
respond
);
const readHandler = asyncHandler(async (req, res, next) => {
const service = new SharesService({
accountability: req.accountability,
schema: req.schema,
});
const records = await service.readByQuery(req.sanitizedQuery);
res.locals.payload = { data: records || null };
return next();
});
router.get('/', validateBatch('read'), readHandler, respond);
router.search('/', validateBatch('read'), readHandler, respond);
router.get(
`/info/:pk(${UUID_REGEX})`,
asyncHandler(async (req, res, next) => {
const service = new SharesService({
schema: req.schema,
});
const record = await service.readOne(req.params.pk, {
fields: ['id', 'collection', 'item', 'password', 'max_uses', 'times_used', 'date_start', 'date_end'],
filter: {
_and: [
{
_or: [
{
date_start: {
_lte: '$NOW',
},
},
{
date_start: {
_null: true,
},
},
],
},
{
_or: [
{
date_end: {
_gte: '$NOW',
},
},
{
date_end: {
_null: true,
},
},
],
},
],
},
});
res.locals.payload = { data: record || null };
return next();
}),
respond
);
router.get(
`/:pk(${UUID_REGEX})`,
asyncHandler(async (req, res, next) => {
const service = new SharesService({
accountability: req.accountability,
schema: req.schema,
});
const record = await service.readOne(req.params.pk, req.sanitizedQuery);
res.locals.payload = { data: record || null };
return next();
}),
respond
);
router.patch(
'/',
validateBatch('update'),
asyncHandler(async (req, res, next) => {
const service = new SharesService({
accountability: req.accountability,
schema: req.schema,
});
let keys: PrimaryKey[] = [];
if (req.body.keys) {
keys = await service.updateMany(req.body.keys, req.body.data);
} else {
keys = await service.updateByQuery(req.body.query, req.body.data);
}
try {
const result = await service.readMany(keys, req.sanitizedQuery);
res.locals.payload = { data: result };
} catch (error) {
if (error instanceof ForbiddenException) {
return next();
}
throw error;
}
return next();
}),
respond
);
router.patch(
`/:pk(${UUID_REGEX})`,
asyncHandler(async (req, res, next) => {
const service = new SharesService({
accountability: req.accountability,
schema: req.schema,
});
const primaryKey = await service.updateOne(req.params.pk, req.body);
try {
const item = await service.readOne(primaryKey, req.sanitizedQuery);
res.locals.payload = { data: item || null };
} catch (error) {
if (error instanceof ForbiddenException) {
return next();
}
throw error;
}
return next();
}),
respond
);
router.delete(
'/',
asyncHandler(async (req, _res, next) => {
const service = new SharesService({
accountability: req.accountability,
schema: req.schema,
});
if (Array.isArray(req.body)) {
await service.deleteMany(req.body);
} else if (req.body.keys) {
await service.deleteMany(req.body.keys);
} else {
await service.deleteByQuery(req.body.query);
}
return next();
}),
respond
);
router.delete(
`/:pk(${UUID_REGEX})`,
asyncHandler(async (req, _res, next) => {
const service = new SharesService({
accountability: req.accountability,
schema: req.schema,
});
await service.deleteOne(req.params.pk);
return next();
}),
respond
);
export default router;

View File

@@ -56,6 +56,7 @@ const readHandler = asyncHandler(async (req, res, next) => {
accountability: req.accountability,
schema: req.schema,
});
const metaService = new MetaService({
accountability: req.accountability,
schema: req.schema,
@@ -74,6 +75,19 @@ router.search('/', validateBatch('read'), readHandler, respond);
router.get(
'/me',
asyncHandler(async (req, res, next) => {
if (req.accountability?.share_scope) {
const user = {
share: req.accountability?.share,
role: {
id: req.accountability.role,
admin_access: false,
app_access: false,
},
};
res.locals.payload = { data: user };
return next();
}
if (!req.accountability?.user) {
throw new InvalidCredentialsException();
}
@@ -140,7 +154,7 @@ router.patch(
router.patch(
'/me/track/page',
asyncHandler(async (req, res, next) => {
asyncHandler(async (req, _res, next) => {
if (!req.accountability?.user) {
throw new InvalidCredentialsException();
}
@@ -219,7 +233,7 @@ router.patch(
router.delete(
'/',
validateBatch('delete'),
asyncHandler(async (req, res, next) => {
asyncHandler(async (req, _res, next) => {
const service = new UsersService({
accountability: req.accountability,
schema: req.schema,
@@ -240,7 +254,7 @@ router.delete(
router.delete(
'/:pk',
asyncHandler(async (req, res, next) => {
asyncHandler(async (req, _res, next) => {
const service = new UsersService({
accountability: req.accountability,
schema: req.schema,
@@ -261,7 +275,7 @@ const inviteSchema = Joi.object({
router.post(
'/invite',
asyncHandler(async (req, res, next) => {
asyncHandler(async (req, _res, next) => {
const { error } = inviteSchema.validate(req.body);
if (error) throw new InvalidPayloadException(error.message);
@@ -282,7 +296,7 @@ const acceptInviteSchema = Joi.object({
router.post(
'/invite/accept',
asyncHandler(async (req, res, next) => {
asyncHandler(async (req, _res, next) => {
const { error } = acceptInviteSchema.validate(req.body);
if (error) throw new InvalidPayloadException(error.message);
const service = new UsersService({
@@ -327,7 +341,7 @@ router.post(
router.post(
'/me/tfa/enable/',
asyncHandler(async (req, res, next) => {
asyncHandler(async (req, _res, next) => {
if (!req.accountability?.user) {
throw new InvalidCredentialsException();
}
@@ -354,7 +368,7 @@ router.post(
router.post(
'/me/tfa/disable',
asyncHandler(async (req, res, next) => {
asyncHandler(async (req, _res, next) => {
if (!req.accountability?.user) {
throw new InvalidCredentialsException();
}