From dbf35a1736f8068a9bbc768cb5645d96c0777285 Mon Sep 17 00:00:00 2001 From: Rijk van Zanten Date: Thu, 23 Dec 2021 18:51:59 -0500 Subject: [PATCH] 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 Co-authored-by: Oreilles <33065839+oreilles@users.noreply.github.com> Co-authored-by: Ben Haynes Co-authored-by: Thien Nguyen <72242664+tatthien@users.noreply.github.com> Co-authored-by: Azri Kahar <42867097+azrikahar@users.noreply.github.com> --- api/src/app.ts | 8 +- api/src/auth/auth.ts | 18 +- api/src/auth/drivers/ldap.ts | 8 +- api/src/auth/drivers/local.ts | 19 +- api/src/auth/drivers/oauth2.ts | 10 +- api/src/auth/drivers/openid.ts | 10 +- api/src/cli/commands/schema/apply.ts | 2 +- api/src/constants.ts | 14 +- api/src/controllers/roles.ts | 1 + api/src/controllers/shares.ts | 287 +++++++++++++++++ api/src/controllers/users.ts | 28 +- .../migrations/20211211A-add-shares.ts | 38 +++ api/src/database/run-ast.ts | 10 +- .../app-access-permissions.yaml | 15 - .../app-access-permissions/index.ts | 6 +- .../schema-access-permissions.yaml | 17 + .../system-data/collections/collections.yaml | 3 + .../database/system-data/fields/sessions.yaml | 2 +- .../database/system-data/fields/shares.yaml | 73 +++++ .../system-data/relations/relations.yaml | 15 + api/src/middleware/authenticate.ts | 27 +- api/src/middleware/check-ip.ts | 14 +- api/src/services/activity.ts | 3 +- api/src/services/authentication.ts | 177 ++++++----- api/src/services/authorization.ts | 6 +- api/src/services/collections.ts | 8 +- api/src/services/files.ts | 4 +- api/src/services/graphql.ts | 19 +- api/src/services/index.ts | 1 + api/src/services/items.ts | 27 +- api/src/services/notifications.ts | 4 +- api/src/services/permissions.ts | 4 +- api/src/services/roles.ts | 4 +- api/src/services/shares.ts | 156 +++++++++ api/src/services/specifications.ts | 2 +- api/src/services/users.ts | 4 +- api/src/services/webhooks.ts | 4 +- api/src/types/ast.ts | 6 +- api/src/types/auth.ts | 34 ++ api/src/types/items.ts | 17 + api/src/utils/apply-query.ts | 133 +++++--- api/src/utils/get-ast-from-query.ts | 6 +- api/src/utils/get-permissions.ts | 25 +- api/src/utils/get-relation-type.ts | 4 +- api/src/utils/merge-permissions-for-share.ts | 180 +++++++++++ api/src/utils/merge-permissions.ts | 73 +++-- api/src/utils/reduce-schema.ts | 10 +- api/src/utils/user-name.ts | 4 + app/src/auth.ts | 23 +- app/src/composables/use-permissions.ts | 5 +- app/src/hydrate.ts | 4 +- app/src/interfaces/file/file.vue | 2 + app/src/interfaces/list-m2a/list-m2a.vue | 2 +- app/src/interfaces/list-o2m/list-o2m.vue | 9 +- app/src/lang/translations/en-US.yaml | 22 ++ app/src/modules/content/routes/item.vue | 16 +- .../settings/routes/roles/app-permissions.ts | 45 +++ .../permissions-overview-header.vue | 1 + .../components/permissions-overview-row.vue | 8 + .../composables/use-update-permissions.ts | 15 +- .../permissions-detail/permissions-detail.vue | 2 +- app/src/router.ts | 9 + .../routes/login/components/continue-as.vue | 5 + .../login/components/login-form/ldap-form.vue | 2 +- .../components/login-form/login-form.vue | 2 +- app/src/routes/login/login.vue | 4 +- .../routes/shared/components/share-item.vue | 36 +++ app/src/routes/shared/index.ts | 4 + app/src/routes/shared/shared.vue | 235 ++++++++++++++ app/src/stores/notifications.ts | 10 +- app/src/stores/presets.ts | 5 +- app/src/stores/settings.ts | 4 + app/src/stores/user.ts | 32 +- app/src/types/index.ts | 1 + app/src/utils/is-allowed.ts | 6 +- app/src/utils/user-name.ts | 4 + .../components/drawer-item/drawer-item.vue | 8 +- .../module-bar-avatar/module-bar-avatar.vue | 4 +- .../components/shares-sidebar-detail/index.ts | 4 + .../shares-sidebar-detail/share-item.vue | 203 ++++++++++++ .../shares-sidebar-detail.vue | 295 ++++++++++++++++++ app/src/views/private/private-view.vue | 2 +- app/src/views/public/readme.md | 22 -- app/src/views/register.ts | 2 + app/src/views/shared/shared-view.vue | 179 +++++++++++ packages/shared/src/types/accountability.ts | 7 + packages/shared/src/types/index.ts | 1 + packages/shared/src/types/permissions.ts | 2 +- packages/shared/src/types/shares.ts | 16 + 89 files changed, 2422 insertions(+), 376 deletions(-) create mode 100644 api/src/controllers/shares.ts create mode 100644 api/src/database/migrations/20211211A-add-shares.ts create mode 100644 api/src/database/system-data/app-access-permissions/schema-access-permissions.yaml create mode 100644 api/src/database/system-data/fields/shares.yaml create mode 100644 api/src/services/shares.ts create mode 100644 api/src/utils/merge-permissions-for-share.ts create mode 100644 app/src/routes/shared/components/share-item.vue create mode 100644 app/src/routes/shared/index.ts create mode 100644 app/src/routes/shared/shared.vue create mode 100644 app/src/views/private/components/shares-sidebar-detail/index.ts create mode 100644 app/src/views/private/components/shares-sidebar-detail/share-item.vue create mode 100644 app/src/views/private/components/shares-sidebar-detail/shares-sidebar-detail.vue delete mode 100644 app/src/views/public/readme.md create mode 100644 app/src/views/shared/shared-view.vue create mode 100644 packages/shared/src/types/shares.ts diff --git a/api/src/app.ts b/api/src/app.ts index 57206ed0ca..81a898754d 100644 --- a/api/src/app.ts +++ b/api/src/app.ts @@ -28,6 +28,7 @@ import settingsRouter from './controllers/settings'; import usersRouter from './controllers/users'; import utilsRouter from './controllers/utils'; import webhooksRouter from './controllers/webhooks'; +import sharesRouter from './controllers/shares'; import { isInstalled, validateDatabaseConnection, validateDatabaseExtensions, validateMigrations } from './database'; import emitter from './emitter'; import env from './env'; @@ -112,7 +113,7 @@ export default async function createApp(): Promise { app.use(extractToken); - app.use((req, res, next) => { + app.use((_req, res, next) => { res.setHeader('X-Powered-By', 'Directus'); next(); }); @@ -121,7 +122,7 @@ export default async function createApp(): Promise { app.use(cors); } - app.get('/', (req, res, next) => { + app.get('/', (_req, res, next) => { if (env.ROOT_REDIRECT) { res.redirect(env.ROOT_REDIRECT); } else { @@ -137,7 +138,7 @@ export default async function createApp(): Promise { const html = await fse.readFile(adminPath, 'utf8'); const htmlWithBase = html.replace(//, ``); - const noCacheIndexHtmlHandler = (req: Request, res: Response) => { + const noCacheIndexHtmlHandler = (_req: Request, res: Response) => { res.setHeader('Cache-Control', 'no-cache'); res.send(htmlWithBase); }; @@ -190,6 +191,7 @@ export default async function createApp(): Promise { app.use('/roles', rolesRouter); app.use('/server', serverRouter); app.use('/settings', settingsRouter); + app.use('/shares', sharesRouter); app.use('/users', usersRouter); app.use('/utils', utilsRouter); app.use('/webhooks', webhooksRouter); diff --git a/api/src/auth/auth.ts b/api/src/auth/auth.ts index 5121551d84..92236e2b78 100644 --- a/api/src/auth/auth.ts +++ b/api/src/auth/auth.ts @@ -1,5 +1,5 @@ import { Knex } from 'knex'; -import { AuthDriverOptions, SchemaOverview, User, SessionData } from '../types'; +import { AuthDriverOptions, SchemaOverview, User } from '../types'; export abstract class AuthDriver { knex: Knex; @@ -36,30 +36,26 @@ export abstract class AuthDriver { * @throws InvalidCredentialsException * @returns Data to be stored with the session */ - async login(_user: User, _payload: Record): Promise { - /* Optional, though should probably be set */ - return null; + async login(_user: User, _payload: Record): Promise { + return; } /** * Handle user session refresh * * @param _user User information - * @param _sessionData Session data * @throws InvalidCredentialsException */ - async refresh(_user: User, sessionData: SessionData): Promise { - /* Optional */ - return sessionData; + async refresh(_user: User): Promise { + return; } /** * Handle user session termination * * @param _user User information - * @param _sessionData Session data */ - async logout(_user: User, _sessionData: SessionData): Promise { - /* Optional */ + async logout(_user: User): Promise { + return; } } diff --git a/api/src/auth/drivers/ldap.ts b/api/src/auth/drivers/ldap.ts index 3afb355a76..976dd27f8a 100644 --- a/api/src/auth/drivers/ldap.ts +++ b/api/src/auth/drivers/ldap.ts @@ -14,7 +14,7 @@ import ldap, { import ms from 'ms'; import Joi from 'joi'; import { AuthDriver } from '../auth'; -import { AuthDriverOptions, User, SessionData } from '../../types'; +import { AuthDriverOptions, User } from '../../types'; import { InvalidCredentialsException, InvalidPayloadException, @@ -318,12 +318,11 @@ export class LDAPAuthDriver extends AuthDriver { }); } - async login(user: User, payload: Record): Promise { + async login(user: User, payload: Record): Promise { await this.verify(user, payload.password); - return null; } - async refresh(user: User): Promise { + async refresh(user: User): Promise { await this.validateBindClient(); const userInfo = await this.fetchUserInfo(user.external_identifier!); @@ -331,7 +330,6 @@ export class LDAPAuthDriver extends AuthDriver { if (userInfo?.userAccountControl && userInfo.userAccountControl & INVALID_ACCOUNT_FLAGS) { throw new InvalidCredentialsException(); } - return null; } } diff --git a/api/src/auth/drivers/local.ts b/api/src/auth/drivers/local.ts index d4d378fed7..472bbe9ede 100644 --- a/api/src/auth/drivers/local.ts +++ b/api/src/auth/drivers/local.ts @@ -1,14 +1,14 @@ import { Router } from 'express'; import argon2 from 'argon2'; -import ms from 'ms'; import Joi from 'joi'; import { AuthDriver } from '../auth'; -import { User, SessionData } from '../../types'; +import { User } from '../../types'; import { InvalidCredentialsException, InvalidPayloadException } from '../../exceptions'; import { AuthenticationService } from '../../services'; import asyncHandler from '../../utils/async-handler'; import env from '../../env'; import { respond } from '../../middleware/respond'; +import { COOKIE_OPTIONS } from '../../constants'; export class LocalAuthDriver extends AuthDriver { async getUserID(payload: Record): Promise { @@ -35,16 +35,15 @@ export class LocalAuthDriver extends AuthDriver { } } - async login(user: User, payload: Record): Promise { + async login(user: User, payload: Record): Promise { await this.verify(user, payload.password); - return null; } } export function createLocalAuthRouter(provider: string): Router { const router = Router(); - const loginSchema = Joi.object({ + const userLoginSchema = Joi.object({ email: Joi.string().email().required(), password: Joi.string().required(), mode: Joi.string().valid('cookie', 'json'), @@ -65,7 +64,7 @@ export function createLocalAuthRouter(provider: string): Router { schema: req.schema, }); - const { error } = loginSchema.validate(req.body); + const { error } = userLoginSchema.validate(req.body); if (error) { throw new InvalidPayloadException(error.message); @@ -88,13 +87,7 @@ export function createLocalAuthRouter(provider: string): Router { } if (mode === 'cookie') { - res.cookie(env.REFRESH_TOKEN_COOKIE_NAME, refreshToken, { - httpOnly: true, - domain: env.REFRESH_TOKEN_COOKIE_DOMAIN, - maxAge: ms(env.REFRESH_TOKEN_TTL as string), - secure: env.REFRESH_TOKEN_COOKIE_SECURE ?? false, - sameSite: (env.REFRESH_TOKEN_COOKIE_SAME_SITE as 'lax' | 'strict' | 'none') || 'strict', - }); + res.cookie(env.REFRESH_TOKEN_COOKIE_NAME, refreshToken, COOKIE_OPTIONS); } res.locals.payload = payload; diff --git a/api/src/auth/drivers/oauth2.ts b/api/src/auth/drivers/oauth2.ts index 10f5dcbd30..dfa955ce91 100644 --- a/api/src/auth/drivers/oauth2.ts +++ b/api/src/auth/drivers/oauth2.ts @@ -6,7 +6,7 @@ import { LocalAuthDriver } from './local'; import { getAuthProvider } from '../../auth'; import env from '../../env'; import { AuthenticationService, UsersService } from '../../services'; -import { AuthDriverOptions, User, AuthData, SessionData } from '../../types'; +import { AuthDriverOptions, User, AuthData } from '../../types'; import { InvalidCredentialsException, ServiceUnavailableException, @@ -159,11 +159,11 @@ export class OAuth2AuthDriver extends LocalAuthDriver { return (await this.fetchUserId(identifier)) as string; } - async login(user: User): Promise { - return this.refresh(user, null); + async login(user: User): Promise { + return this.refresh(user); } - async refresh(user: User, sessionData: SessionData): Promise { + async refresh(user: User): Promise { let authData = user.auth_data as AuthData; if (typeof authData === 'string') { @@ -187,8 +187,6 @@ export class OAuth2AuthDriver extends LocalAuthDriver { throw handleError(e); } } - - return sessionData; } } diff --git a/api/src/auth/drivers/openid.ts b/api/src/auth/drivers/openid.ts index c728a7d007..7347327620 100644 --- a/api/src/auth/drivers/openid.ts +++ b/api/src/auth/drivers/openid.ts @@ -6,7 +6,7 @@ import { LocalAuthDriver } from './local'; import { getAuthProvider } from '../../auth'; import env from '../../env'; import { AuthenticationService, UsersService } from '../../services'; -import { AuthDriverOptions, User, AuthData, SessionData } from '../../types'; +import { AuthDriverOptions, User, AuthData } from '../../types'; import { InvalidCredentialsException, ServiceUnavailableException, @@ -167,11 +167,11 @@ export class OpenIDAuthDriver extends LocalAuthDriver { return (await this.fetchUserId(identifier)) as string; } - async login(user: User): Promise { - return this.refresh(user, null); + async login(user: User): Promise { + return this.refresh(user); } - async refresh(user: User, sessionData: SessionData): Promise { + async refresh(user: User): Promise { let authData = user.auth_data as AuthData; if (typeof authData === 'string') { @@ -196,8 +196,6 @@ export class OpenIDAuthDriver extends LocalAuthDriver { throw handleError(e); } } - - return sessionData; } } diff --git a/api/src/cli/commands/schema/apply.ts b/api/src/cli/commands/schema/apply.ts index 7e2d64a55e..445499fee4 100644 --- a/api/src/cli/commands/schema/apply.ts +++ b/api/src/cli/commands/schema/apply.ts @@ -122,7 +122,7 @@ export async function apply(snapshotPath: string, options?: { yes: boolean }): P continue; } - // Related collection doesn't exist for m2a relationship types + // Related collection doesn't exist for a2o relationship types if (related_collection) { message += `-> ${related_collection}`; } diff --git a/api/src/constants.ts b/api/src/constants.ts index 56729b3210..01804b0369 100644 --- a/api/src/constants.ts +++ b/api/src/constants.ts @@ -1,4 +1,6 @@ import { TransformationParams } from './types'; +import env from './env'; +import ms from 'ms'; export const SYSTEM_ASSET_ALLOW_LIST: TransformationParams[] = [ { @@ -40,8 +42,18 @@ export const ASSET_TRANSFORM_QUERY_KEYS = [ export const FILTER_VARIABLES = ['$NOW', '$CURRENT_USER', '$CURRENT_ROLE']; -export const ALIAS_TYPES = ['alias', 'o2m', 'm2m', 'm2a', 'files', 'translations']; +export const ALIAS_TYPES = ['alias', 'o2m', 'm2m', 'm2a', 'o2a', 'files', 'translations']; export const DEFAULT_AUTH_PROVIDER = 'default'; export const COLUMN_TRANSFORMS = ['year', 'month', 'day', 'weekday', 'hour', 'minute', 'second']; + +export const UUID_REGEX = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'; + +export const COOKIE_OPTIONS = { + httpOnly: true, + domain: env.REFRESH_TOKEN_COOKIE_DOMAIN, + maxAge: ms(env.REFRESH_TOKEN_TTL as string), + secure: env.REFRESH_TOKEN_COOKIE_SECURE ?? false, + sameSite: (env.REFRESH_TOKEN_COOKIE_SAME_SITE as 'lax' | 'strict' | 'none') || 'strict', +}; diff --git a/api/src/controllers/roles.ts b/api/src/controllers/roles.ts index 8b47ecf05b..be9b402b02 100644 --- a/api/src/controllers/roles.ts +++ b/api/src/controllers/roles.ts @@ -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, diff --git a/api/src/controllers/shares.ts b/api/src/controllers/shares.ts new file mode 100644 index 0000000000..3dfe1b42bd --- /dev/null +++ b/api/src/controllers/shares.ts @@ -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; diff --git a/api/src/controllers/users.ts b/api/src/controllers/users.ts index 71b92d9ed2..b9c91d2673 100644 --- a/api/src/controllers/users.ts +++ b/api/src/controllers/users.ts @@ -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(); } diff --git a/api/src/database/migrations/20211211A-add-shares.ts b/api/src/database/migrations/20211211A-add-shares.ts new file mode 100644 index 0000000000..0673d4816a --- /dev/null +++ b/api/src/database/migrations/20211211A-add-shares.ts @@ -0,0 +1,38 @@ +import { Knex } from 'knex'; + +export async function up(knex: Knex): Promise { + await knex.schema.createTable('directus_shares', (table) => { + table.uuid('id').primary(); + table.string('name'); + table.string('collection', 64).references('collection').inTable('directus_collections').onDelete('CASCADE'); + table.string('item'); + table.uuid('role').references('id').inTable('directus_roles').onDelete('CASCADE'); + table.string('password'); + table.uuid('user_created').references('id').inTable('directus_users').onDelete('SET NULL'); + table.timestamp('date_created').defaultTo(knex.fn.now()); + table.timestamp('date_start'); + table.timestamp('date_end'); + table.integer('times_used').defaultTo(0); + table.integer('max_uses'); + }); + + await knex.schema.alterTable('directus_sessions', (table) => { + table.dropColumn('data'); + }); + + await knex.schema.alterTable('directus_sessions', (table) => { + table.uuid('user').nullable().alter(); + table.uuid('share').references('id').inTable('directus_shares').onDelete('CASCADE'); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.alterTable('directus_sessions', (table) => { + table.uuid('user').notNullable().alter(); + table.json('data'); + table.dropForeign('share'); + table.dropColumn('share'); + }); + + await knex.schema.dropTable('directus_shares'); +} diff --git a/api/src/database/run-ast.ts b/api/src/database/run-ast.ts index a9bfc9d682..946f502ac9 100644 --- a/api/src/database/run-ast.ts +++ b/api/src/database/run-ast.ts @@ -46,7 +46,7 @@ export default async function runAST( const knex = options?.knex || getDatabase(); - if (ast.type === 'm2a') { + if (ast.type === 'a2o') { const results: { [collection: string]: null | Item | Item[] } = {}; for (const collection of ast.names) { @@ -141,7 +141,7 @@ async function parseCurrentLevel( columnsToSelectInternal.push(child.fieldKey); } - if (child.type === 'm2a') { + if (child.type === 'a2o') { columnsToSelectInternal.push(child.relation.field); columnsToSelectInternal.push(child.relation.meta!.one_collection_field!); } @@ -263,7 +263,7 @@ function applyParentFilters( } else { nestedNode.query.union = [foreignField, foreignIds]; } - } else if (nestedNode.type === 'm2a') { + } else if (nestedNode.type === 'a2o') { const keysPerCollection: { [collection: string]: (string | number)[] } = {}; for (const parentItem of parentItems) { @@ -346,7 +346,7 @@ function mergeWithParentItems( parentItem[nestedNode.fieldKey] = itemChildren.length > 0 ? itemChildren : []; } - } else if (nestedNode.type === 'm2a') { + } else if (nestedNode.type === 'a2o') { for (const parentItem of parentItems) { if (!nestedNode.relation.meta?.one_collection_field) { parentItem[nestedNode.fieldKey] = null; @@ -381,7 +381,7 @@ function removeTemporaryFields( const rawItems = cloneDeep(toArray(rawItem)); const items: Item[] = []; - if (ast.type === 'm2a') { + if (ast.type === 'a2o') { const fields: Record = {}; const nestedCollectionNodes: Record = {}; diff --git a/api/src/database/system-data/app-access-permissions/app-access-permissions.yaml b/api/src/database/system-data/app-access-permissions/app-access-permissions.yaml index c29d520d80..b65e639f28 100644 --- a/api/src/database/system-data/app-access-permissions/app-access-permissions.yaml +++ b/api/src/database/system-data/app-access-permissions/app-access-permissions.yaml @@ -13,18 +13,6 @@ comment: _nnull: true -- collection: directus_collections - action: read - -- collection: directus_fields - action: read - -- collection: directus_permissions - action: read - permissions: - role: - _eq: $CURRENT_ROLE - - collection: directus_presets action: read permissions: @@ -60,9 +48,6 @@ user: _eq: $CURRENT_USER -- collection: directus_relations - action: read - - collection: directus_roles action: read permissions: diff --git a/api/src/database/system-data/app-access-permissions/index.ts b/api/src/database/system-data/app-access-permissions/index.ts index 1533da5532..9516dffd0a 100644 --- a/api/src/database/system-data/app-access-permissions/index.ts +++ b/api/src/database/system-data/app-access-permissions/index.ts @@ -11,6 +11,10 @@ const defaults: Partial = { system: true, }; +const schemaPermissionsRaw = requireYAML(require.resolve('./schema-access-permissions.yaml')) as Permission[]; const permissions = requireYAML(require.resolve('./app-access-permissions.yaml')) as Permission[]; -export const appAccessMinimalPermissions: Permission[] = permissions.map((row) => merge({}, defaults, row)); +export const schemaPermissions: Permission[] = schemaPermissionsRaw.map((row) => merge({}, defaults, row)); +export const appAccessMinimalPermissions: Permission[] = [...schemaPermissions, ...permissions].map((row) => + merge({}, defaults, row) +); diff --git a/api/src/database/system-data/app-access-permissions/schema-access-permissions.yaml b/api/src/database/system-data/app-access-permissions/schema-access-permissions.yaml new file mode 100644 index 0000000000..1f2a2ce25e --- /dev/null +++ b/api/src/database/system-data/app-access-permissions/schema-access-permissions.yaml @@ -0,0 +1,17 @@ +# NOTE: Activity/collections/fields/presets/relations/revisions will have an extra hardcoded filter +# to filter out collections you don't have read access + +- collection: directus_collections + action: read + +- collection: directus_fields + action: read + +- collection: directus_permissions + action: read + permissions: + role: + _eq: $CURRENT_ROLE + +- collection: directus_relations + action: read diff --git a/api/src/database/system-data/collections/collections.yaml b/api/src/database/system-data/collections/collections.yaml index 283c7932d5..6a982521b7 100644 --- a/api/src/database/system-data/collections/collections.yaml +++ b/api/src/database/system-data/collections/collections.yaml @@ -65,3 +65,6 @@ data: note: $t:directus_collection.directus_panels - collection: directus_notifications note: $t:directus_collection.directus_notifications + - collection: directus_shares + icon: share + note: $t:directus_collection.directus_shares diff --git a/api/src/database/system-data/fields/sessions.yaml b/api/src/database/system-data/fields/sessions.yaml index 7fbe834cda..7c2af8e849 100644 --- a/api/src/database/system-data/fields/sessions.yaml +++ b/api/src/database/system-data/fields/sessions.yaml @@ -11,4 +11,4 @@ fields: width: half - field: user_agent width: half - - field: data + - field: share diff --git a/api/src/database/system-data/fields/shares.yaml b/api/src/database/system-data/fields/shares.yaml new file mode 100644 index 0000000000..30bf2dcf55 --- /dev/null +++ b/api/src/database/system-data/fields/shares.yaml @@ -0,0 +1,73 @@ +table: directus_shares + +fields: + - field: id + special: uuid + readonly: true + hidden: true + + - field: name + + - field: collection + width: half + hidden: true + + - field: item + width: half + hidden: true + + - field: role + interface: select-dropdown-m2o + width: half + options: + template: '{{name}}' + filter: + admin_access: + _eq: false + + - field: password + special: hash,conceal + interface: input-hash + options: + iconRight: lock + masked: true + width: half + + - field: date_start + width: half + + - field: date_end + width: half + + - field: max_uses + width: half + + - field: times_used + width: half + readonly: true + + - field: date_created + special: date-created + width: half + readonly: true + conditions: + - name: notCreatedYet + rule: + id: + _null: true + hidden: true + + - field: user_created + special: user-created + interface: select-dropdown-m2o + width: half + display: user + options: + template: '{{avatar.$thumbnail}} {{first_name}} {{last_name}}' + readonly: true + conditions: + - name: notCreatedYet + rule: + id: + _null: true + hidden: true diff --git a/api/src/database/system-data/relations/relations.yaml b/api/src/database/system-data/relations/relations.yaml index f1bfe67ae3..39a526398f 100644 --- a/api/src/database/system-data/relations/relations.yaml +++ b/api/src/database/system-data/relations/relations.yaml @@ -12,6 +12,9 @@ defaults: sort_field: null data: + - many_collection: directus_collections + many_field: group + one_collection: directus_collections - many_collection: directus_users many_field: role one_collection: directus_roles @@ -73,6 +76,9 @@ data: - many_collection: directus_sessions many_field: user one_collection: directus_users + - many_collection: directus_sessions + many_field: share + one_collection: directus_shares - many_collection: directus_settings many_field: storage_default_folder one_collection: directus_folders @@ -88,3 +94,12 @@ data: - many_collection: directus_notifications many_field: sender one_collection: directus_users + - many_collection: directus_shares + many_field: role + one_collection: directus_roles + - many_collection: directus_shares + many_field: collection + one_collection: directus_collections + - many_collection: directus_shares + many_field: user_created + one_collection: directus_users diff --git a/api/src/middleware/authenticate.ts b/api/src/middleware/authenticate.ts index 02dbdde31d..bf6f22d3db 100644 --- a/api/src/middleware/authenticate.ts +++ b/api/src/middleware/authenticate.ts @@ -3,6 +3,7 @@ import jwt, { JsonWebTokenError, TokenExpiredError } from 'jsonwebtoken'; import getDatabase from '../database'; import env from '../env'; import { InvalidCredentialsException } from '../exceptions'; +import { DirectusTokenPayload } from '../types'; import asyncHandler from '../utils/async-handler'; import isDirectusJWT from '../utils/is-directus-jwt'; @@ -23,10 +24,10 @@ const authenticate: RequestHandler = asyncHandler(async (req, res, next) => { if (req.token) { if (isDirectusJWT(req.token)) { - let payload: { id: string }; + let payload: DirectusTokenPayload; try { - payload = jwt.verify(req.token, env.SECRET as string, { issuer: 'directus' }) as { id: string }; + payload = jwt.verify(req.token, env.SECRET as string, { issuer: 'directus' }) as DirectusTokenPayload; } catch (err: any) { if (err instanceof TokenExpiredError) { throw new InvalidCredentialsException('Token expired.'); @@ -37,24 +38,12 @@ const authenticate: RequestHandler = asyncHandler(async (req, res, next) => { } } - const user = await database - .select('directus_users.role', 'directus_roles.admin_access', 'directus_roles.app_access') - .from('directus_users') - .leftJoin('directus_roles', 'directus_users.role', 'directus_roles.id') - .where({ - 'directus_users.id': payload.id, - status: 'active', - }) - .first(); - - if (!user) { - throw new InvalidCredentialsException(); - } - + req.accountability.share = payload.share; + req.accountability.share_scope = payload.share_scope; req.accountability.user = payload.id; - req.accountability.role = user.role; - req.accountability.admin = user.admin_access === true || user.admin_access == 1; - req.accountability.app = user.app_access === true || user.app_access == 1; + req.accountability.role = payload.role; + req.accountability.admin = payload.admin_access === true || payload.admin_access == 1; + req.accountability.app = payload.app_access === true || payload.app_access == 1; } else { // Try finding the user with the provided token const user = await database diff --git a/api/src/middleware/check-ip.ts b/api/src/middleware/check-ip.ts index bec0e367f5..6c58b4700d 100644 --- a/api/src/middleware/check-ip.ts +++ b/api/src/middleware/check-ip.ts @@ -3,14 +3,16 @@ import getDatabase from '../database'; import { InvalidIPException } from '../exceptions'; import asyncHandler from '../utils/async-handler'; -export const checkIP: RequestHandler = asyncHandler(async (req, res, next) => { +export const checkIP: RequestHandler = asyncHandler(async (req, _res, next) => { const database = getDatabase(); - const role = await database - .select('ip_access') - .from('directus_roles') - .where({ id: req.accountability!.role }) - .first(); + const query = database.select('ip_access').from('directus_roles'); + if (req.accountability!.role) { + query.where({ id: req.accountability!.role }); + } else { + query.whereNull('id'); + } + const role = await query.first(); const ipAllowlist = (role?.ip_access || '').split(',').filter((ip: string) => ip); diff --git a/api/src/services/activity.ts b/api/src/services/activity.ts index 64ef0be871..9c70a609cc 100644 --- a/api/src/services/activity.ts +++ b/api/src/services/activity.ts @@ -1,5 +1,6 @@ import { AbstractServiceOptions, PrimaryKey, Item, Action } from '../types'; -import { ItemsService, MutationOptions } from './index'; +import { ItemsService } from './items'; +import { MutationOptions } from '../types'; import { NotificationsService } from './notifications'; import { UsersService } from './users'; import { AuthorizationService } from './authorization'; diff --git a/api/src/services/authentication.ts b/api/src/services/authentication.ts index 90be2d8aa7..1f648e0134 100644 --- a/api/src/services/authentication.ts +++ b/api/src/services/authentication.ts @@ -11,13 +11,20 @@ import { InvalidCredentialsException, InvalidOTPException, UserSuspendedExceptio import { createRateLimiter } from '../rate-limiter'; import { ActivityService } from './activity'; import { TFAService } from './tfa'; -import { AbstractServiceOptions, Action, SchemaOverview, Session, User, SessionData } from '../types'; +import { + AbstractServiceOptions, + Action, + SchemaOverview, + Session, + User, + DirectusTokenPayload, + LoginResult, +} from '../types'; import { Accountability } from '@directus/shared/types'; import { SettingsService } from './settings'; -import { clone, cloneDeep, omit } from 'lodash'; +import { clone, cloneDeep } from 'lodash'; import { performance } from 'perf_hooks'; import { stall } from '../utils/stall'; -import logger from '../logger'; const loginAttemptsLimiter = createRateLimiter({ duration: 0 }); @@ -44,7 +51,7 @@ export class AuthenticationService { providerName: string = DEFAULT_AUTH_PROVIDER, payload: Record, otp?: string - ): Promise<{ accessToken: any; refreshToken: any; expires: any; id?: any }> { + ): Promise { const STALL_TIME = 100; const timeStart = performance.now(); @@ -52,21 +59,24 @@ export class AuthenticationService { const user = await this.knex .select( - 'id', - 'first_name', - 'last_name', - 'email', - 'password', - 'status', - 'role', - 'tfa_secret', - 'provider', - 'external_identifier', - 'auth_data' + 'u.id', + 'u.first_name', + 'u.last_name', + 'u.email', + 'u.password', + 'u.status', + 'u.role', + 'r.admin_access', + 'r.app_access', + 'u.tfa_secret', + 'u.provider', + 'u.external_identifier', + 'u.auth_data' ) - .from('directus_users') - .where('id', await provider.getUserID(cloneDeep(payload))) - .andWhere('provider', providerName) + .from('directus_users as u') + .innerJoin('directus_roles as r', 'u.role', 'r.id') + .where('u.id', await provider.getUserID(cloneDeep(payload))) + .andWhere('u.provider', providerName) .first(); const updatedPayload = await emitter.emitFilter( @@ -136,10 +146,8 @@ export class AuthenticationService { } } - let sessionData: SessionData = null; - try { - sessionData = await provider.login(clone(user), cloneDeep(updatedPayload)); + await provider.login(clone(user), cloneDeep(updatedPayload)); } catch (e) { emitStatus('fail'); await stall(STALL_TIME, timeStart); @@ -165,6 +173,9 @@ export class AuthenticationService { const tokenPayload = { id: user.id, + role: user.role, + app_access: user.app_access, + admin_access: user.admin_access, }; const customClaims = await emitter.emitFilter( @@ -197,7 +208,6 @@ export class AuthenticationService { expires: refreshTokenExpiration, ip: this.accountability?.ip, user_agent: this.accountability?.userAgent, - data: sessionData && JSON.stringify(sessionData), }); await this.knex('directus_sessions').delete().where('expires', '<', new Date()); @@ -237,55 +247,89 @@ export class AuthenticationService { } const record = await this.knex - .select( - 's.expires', - 's.data', - 'u.id', - 'u.first_name', - 'u.last_name', - 'u.email', - 'u.password', - 'u.status', - 'u.role', - 'u.provider', - 'u.external_identifier', - 'u.auth_data' - ) - .from('directus_sessions as s') - .innerJoin('directus_users as u', 's.user', 'u.id') + .select({ + session_expires: 's.expires', + user_id: 'u.id', + user_first_name: 'u.first_name', + user_last_name: 'u.last_name', + user_email: 'u.email', + user_password: 'u.password', + user_status: 'u.status', + user_provider: 'u.provider', + user_external_identifier: 'u.external_identifier', + user_auth_data: 'u.auth_data', + role_id: 'r.id', + role_admin_access: 'r.admin_access', + role_app_access: 'r.app_access', + share_id: 'd.id', + share_item: 'd.item', + share_role: 'd.role', + share_collection: 'd.collection', + share_start: 'd.date_start', + share_end: 'd.date_end', + share_times_used: 'd.times_used', + share_max_uses: 'd.max_uses', + }) + .from('directus_sessions AS s') + .leftJoin('directus_users AS u', 's.user', 'u.id') + .leftJoin('directus_shares AS d', 's.share', 'd.id') + .joinRaw('LEFT JOIN directus_roles AS r ON r.id IN (u.role, d.role)') .where('s.token', refreshToken) + .andWhere('s.expires', '>=', this.knex.fn.now()) + .andWhere((subQuery) => { + subQuery.whereNull('d.date_end').orWhere('d.date_end', '>=', this.knex.fn.now()); + }) + .andWhere((subQuery) => { + subQuery.whereNull('d.date_start').orWhere('d.date_start', '<=', this.knex.fn.now()); + }) .first(); - if (!record || record.expires < new Date()) { + if (!record || (!record.share_id && !record.user_id)) { throw new InvalidCredentialsException(); } - let { data: sessionData } = record; - const user = omit(record, 'data'); + if (record.user_id) { + const provider = getAuthProvider(record.user_provider); - if (typeof sessionData === 'string') { - try { - sessionData = JSON.parse(sessionData); - } catch { - logger.warn(`Session data isn't valid JSON: ${sessionData}`); - } + await provider.refresh({ + id: record.user_id, + first_name: record.user_first_name, + last_name: record.user_last_name, + email: record.user_email, + password: record.user_password, + status: record.user_status, + provider: record.user_provider, + external_identifier: record.user_external_identifier, + auth_data: record.user_auth_data, + role: record.role_id, + app_access: record.role_app_access, + admin_access: record.role_admin_access, + }); } - const provider = getAuthProvider(user.provider); - - const newSessionData = await provider.refresh(clone(user), sessionData as SessionData); - - const tokenPayload = { - id: user.id, + const tokenPayload: DirectusTokenPayload = { + id: record.user_id, + role: record.role_id, + app_access: record.role_app_access, + admin_access: record.role_admin_access, }; + if (record.share_id) { + tokenPayload.share = record.share_id; + tokenPayload.role = record.share_role; + tokenPayload.share_scope = { + collection: record.share_collection, + item: record.share_item, + }; + } + const customClaims = await emitter.emitFilter( 'auth.jwt', tokenPayload, { status: 'pending', - user: user?.id, - provider: user.provider, + user: record.user_id, + provider: record.user_provider, type: 'refresh', }, { @@ -307,17 +351,18 @@ export class AuthenticationService { .update({ token: newRefreshToken, expires: refreshTokenExpiration, - data: newSessionData && JSON.stringify(newSessionData), }) .where({ token: refreshToken }); - await this.knex('directus_users').update({ last_access: new Date() }).where({ id: user.id }); + if (record.user_id) { + await this.knex('directus_users').update({ last_access: new Date() }).where({ id: record.user_id }); + } return { accessToken, refreshToken: newRefreshToken, expires: ms(env.ACCESS_TOKEN_TTL as string), - id: user.id, + id: record.user_id, }; } @@ -333,8 +378,7 @@ export class AuthenticationService { 'u.role', 'u.provider', 'u.external_identifier', - 'u.auth_data', - 's.data' + 'u.auth_data' ) .from('directus_sessions as s') .innerJoin('directus_users as u', 's.user', 'u.id') @@ -342,19 +386,10 @@ export class AuthenticationService { .first(); if (record) { - let { data: sessionData } = record; - const user = omit(record, 'data'); - - if (typeof sessionData === 'string') { - try { - sessionData = JSON.parse(sessionData); - } catch { - logger.warn(`Session data isn't valid JSON: ${sessionData}`); - } - } + const user = record; const provider = getAuthProvider(user.provider); - await provider.logout(clone(user), sessionData as SessionData); + await provider.logout(clone(user)); await this.knex.delete().from('directus_sessions').where('token', refreshToken); } diff --git a/api/src/services/authorization.ts b/api/src/services/authorization.ts index 3dcab9b51b..036ee44cb9 100644 --- a/api/src/services/authorization.ts +++ b/api/src/services/authorization.ts @@ -66,7 +66,7 @@ export class AuthorizationService { function getCollectionsFromAST(ast: AST | NestedCollectionNode): { collection: string; field: string }[] { const collections = []; - if (ast.type === 'm2a') { + if (ast.type === 'a2o') { collections.push(...ast.names.map((name) => ({ collection: name, field: ast.fieldKey }))); for (const children of Object.values(ast.children)) { @@ -94,7 +94,7 @@ export class AuthorizationService { function validateFields(ast: AST | NestedCollectionNode | FieldNode) { if (ast.type !== 'field') { - if (ast.type === 'm2a') { + if (ast.type === 'a2o') { for (const [collection, children] of Object.entries(ast.children)) { checkFields(collection, children, ast.query?.[collection]?.aggregate); } @@ -144,7 +144,7 @@ export class AuthorizationService { accountability: Accountability | null ): AST | NestedCollectionNode | FieldNode { if (ast.type !== 'field') { - if (ast.type === 'm2a') { + if (ast.type === 'a2o') { const collections = Object.keys(ast.children); for (const collection of collections) { diff --git a/api/src/services/collections.ts b/api/src/services/collections.ts index f9f297f854..b88f112a47 100644 --- a/api/src/services/collections.ts +++ b/api/src/services/collections.ts @@ -7,9 +7,9 @@ import { systemCollectionRows } from '../database/system-data/collections'; import env from '../env'; import { ForbiddenException, InvalidPayloadException } from '../exceptions'; import { FieldsService } from '../services/fields'; -import { ItemsService, MutationOptions } from '../services/items'; +import { ItemsService } from '../services/items'; import Keyv from 'keyv'; -import { AbstractServiceOptions, Collection, CollectionMeta, SchemaOverview } from '../types'; +import { AbstractServiceOptions, Collection, CollectionMeta, SchemaOverview, MutationOptions } from '../types'; import { Accountability, FieldMeta, RawField } from '@directus/shared/types'; import { Table } from 'knex-schema-inspector/dist/types/table'; @@ -432,11 +432,11 @@ export class CollectionsService { } } - const m2aRelationsThatIncludeThisCollection = this.schema.relations.filter((relation) => { + const a2oRelationsThatIncludeThisCollection = this.schema.relations.filter((relation) => { return relation.meta?.one_allowed_collections?.includes(collectionKey); }); - for (const relation of m2aRelationsThatIncludeThisCollection) { + for (const relation of a2oRelationsThatIncludeThisCollection) { const newAllowedCollections = relation .meta!.one_allowed_collections!.filter((collection) => collectionKey !== collection) .join(','); diff --git a/api/src/services/files.ts b/api/src/services/files.ts index fe374530ed..af284eaa2d 100644 --- a/api/src/services/files.ts +++ b/api/src/services/files.ts @@ -11,9 +11,9 @@ import env from '../env'; import { ForbiddenException, ServiceUnavailableException } from '../exceptions'; import logger from '../logger'; import storage from '../storage'; -import { AbstractServiceOptions, File, PrimaryKey } from '../types'; +import { AbstractServiceOptions, File, PrimaryKey, MutationOptions } from '../types'; import { toArray } from '@directus/shared/utils'; -import { ItemsService, MutationOptions } from './items'; +import { ItemsService } from './items'; export class FilesService extends ItemsService { constructor(options: AbstractServiceOptions) { diff --git a/api/src/services/graphql.ts b/api/src/services/graphql.ts index 1eff09bc92..a61f8a62cc 100644 --- a/api/src/services/graphql.ts +++ b/api/src/services/graphql.ts @@ -72,6 +72,7 @@ import { RevisionsService } from './revisions'; import { RolesService } from './roles'; import { ServerService } from './server'; import { SettingsService } from './settings'; +import { SharesService } from './shares'; import { SpecificationService } from './specifications'; import { TFAService } from './tfa'; import { UsersService } from './users'; @@ -188,13 +189,21 @@ export class GraphQLService { const schema = { read: - this.accountability?.admin === true ? this.schema : reduceSchema(this.schema, this.accountability, ['read']), + this.accountability?.admin === true + ? this.schema + : reduceSchema(this.schema, this.accountability?.permissions || null, ['read']), create: - this.accountability?.admin === true ? this.schema : reduceSchema(this.schema, this.accountability, ['create']), + this.accountability?.admin === true + ? this.schema + : reduceSchema(this.schema, this.accountability?.permissions || null, ['create']), update: - this.accountability?.admin === true ? this.schema : reduceSchema(this.schema, this.accountability, ['update']), + this.accountability?.admin === true + ? this.schema + : reduceSchema(this.schema, this.accountability?.permissions || null, ['update']), delete: - this.accountability?.admin === true ? this.schema : reduceSchema(this.schema, this.accountability, ['delete']), + this.accountability?.admin === true + ? this.schema + : reduceSchema(this.schema, this.accountability?.permissions || null, ['delete']), }; const { ReadCollectionTypes } = getReadableTypes(); @@ -1536,6 +1545,8 @@ export class GraphQLService { return new UsersService(opts); case 'directus_webhooks': return new WebhooksService(opts); + case 'directus_shares': + return new SharesService(opts); default: return new ItemsService(collection, opts); } diff --git a/api/src/services/index.ts b/api/src/services/index.ts index 287bd47d18..ab2b025dd0 100644 --- a/api/src/services/index.ts +++ b/api/src/services/index.ts @@ -27,3 +27,4 @@ export * from './tfa'; export * from './users'; export * from './utils'; export * from './webhooks'; +export * from './shares'; diff --git a/api/src/services/items.ts b/api/src/services/items.ts index 709cbe16ba..9aa6f3eee3 100644 --- a/api/src/services/items.ts +++ b/api/src/services/items.ts @@ -9,7 +9,15 @@ import env from '../env'; import { ForbiddenException } from '../exceptions'; import { translateDatabaseError } from '../exceptions/database/translate'; import { Accountability, Query, PermissionsAction } from '@directus/shared/types'; -import { AbstractService, AbstractServiceOptions, Action, Item as AnyItem, PrimaryKey, SchemaOverview } from '../types'; +import { + AbstractService, + AbstractServiceOptions, + Action, + Item as AnyItem, + PrimaryKey, + SchemaOverview, + MutationOptions, +} from '../types'; import getASTFromQuery from '../utils/get-ast-from-query'; import { AuthorizationService } from './authorization'; import { PayloadService } from './payload'; @@ -20,23 +28,6 @@ export type QueryOptions = { permissionsAction?: PermissionsAction; }; -export type MutationOptions = { - /** - * Callback function that's fired whenever a revision is made in the mutation - */ - onRevisionCreate?: (pk: PrimaryKey) => void; - - /** - * Flag to disable the auto purging of the cache. Is ignored when CACHE_AUTO_PURGE isn't enabled. - */ - autoPurgeCache?: false; - - /** - * Allow disabling the emitting of hooks. Useful if a custom hook is fired (like files.upload) - */ - emitEvents?: boolean; -}; - export class ItemsService implements AbstractService { collection: string; knex: Knex; diff --git a/api/src/services/notifications.ts b/api/src/services/notifications.ts index 17cb2c09ac..4fc7500e2c 100644 --- a/api/src/services/notifications.ts +++ b/api/src/services/notifications.ts @@ -1,6 +1,6 @@ import { UsersService, MailService } from '.'; -import { AbstractServiceOptions, PrimaryKey } from '../types'; -import { ItemsService, MutationOptions } from './items'; +import { AbstractServiceOptions, PrimaryKey, MutationOptions } from '../types'; +import { ItemsService } from './items'; import { Notification } from '@directus/shared/types'; import { md } from '../utils/md'; diff --git a/api/src/services/permissions.ts b/api/src/services/permissions.ts index 2a3f3c3d7b..5b5e6f48d9 100644 --- a/api/src/services/permissions.ts +++ b/api/src/services/permissions.ts @@ -1,6 +1,6 @@ import { appAccessMinimalPermissions } from '../database/system-data/app-access-permissions'; -import { ItemsService, QueryOptions, MutationOptions } from '../services/items'; -import { AbstractServiceOptions, Item, PrimaryKey } from '../types'; +import { ItemsService, QueryOptions } from '../services/items'; +import { AbstractServiceOptions, Item, PrimaryKey, MutationOptions } from '../types'; import { Query, PermissionsAction } from '@directus/shared/types'; import { filterItems } from '../utils/filter-items'; import Keyv from 'keyv'; diff --git a/api/src/services/roles.ts b/api/src/services/roles.ts index b5b99d1637..56a486ad19 100644 --- a/api/src/services/roles.ts +++ b/api/src/services/roles.ts @@ -1,7 +1,7 @@ import { ForbiddenException, UnprocessableEntityException } from '../exceptions'; -import { AbstractServiceOptions, PrimaryKey, Alterations, Item } from '../types'; +import { AbstractServiceOptions, MutationOptions, PrimaryKey, Alterations, Item } from '../types'; import { Query } from '@directus/shared/types'; -import { ItemsService, MutationOptions } from './items'; +import { ItemsService } from './items'; import { PermissionsService } from './permissions'; import { PresetsService } from './presets'; import { UsersService } from './users'; diff --git a/api/src/services/shares.ts b/api/src/services/shares.ts new file mode 100644 index 0000000000..721046ec74 --- /dev/null +++ b/api/src/services/shares.ts @@ -0,0 +1,156 @@ +import { + AbstractServiceOptions, + ShareData, + LoginResult, + Item, + PrimaryKey, + MutationOptions, + DirectusTokenPayload, +} from '../types'; +import { ItemsService } from './items'; +import argon2 from 'argon2'; +import jwt from 'jsonwebtoken'; +import ms from 'ms'; +import { InvalidCredentialsException, ForbiddenException } from '../exceptions'; +import env from '../env'; +import { nanoid } from 'nanoid'; +import { AuthorizationService } from './authorization'; +import { UsersService } from './users'; +import { MailService } from './mail'; +import { userName } from '../utils/user-name'; +import { md } from '../utils/md'; + +export class SharesService extends ItemsService { + authorizationService: AuthorizationService; + + constructor(options: AbstractServiceOptions) { + super('directus_shares', options); + + this.authorizationService = new AuthorizationService({ + accountability: this.accountability, + knex: this.knex, + schema: this.schema, + }); + } + + async createOne(data: Partial, opts?: MutationOptions): Promise { + await this.authorizationService.checkAccess('share', data.collection, data.item); + return super.createOne(data, opts); + } + + async login(payload: Record): Promise { + const record = await this.knex + .select({ + share_id: 'id', + share_role: 'role', + share_item: 'item', + share_collection: 'collection', + share_start: 'date_start', + share_end: 'date_end', + share_times_used: 'times_used', + share_max_uses: 'max_uses', + share_password: 'password', + }) + .from('directus_shares') + .where('id', payload.share) + .andWhere((subQuery) => { + subQuery.whereNull('date_end').orWhere('date_end', '>=', this.knex.fn.now()); + }) + .andWhere((subQuery) => { + subQuery.whereNull('date_start').orWhere('date_start', '<=', this.knex.fn.now()); + }) + .andWhere((subQuery) => { + subQuery.whereNull('max_uses').orWhere('max_uses', '>=', this.knex.ref('times_used')); + }) + .first(); + + if (!record) { + throw new InvalidCredentialsException(); + } + + if (record.share_password && !(await argon2.verify(record.share_password, payload.password))) { + throw new InvalidCredentialsException(); + } + + await this.knex('directus_shares') + .update({ times_used: record.share_times_used + 1 }) + .where('id', record.share_id); + + const tokenPayload: DirectusTokenPayload = { + app_access: false, + admin_access: false, + role: record.share_role, + share: record.share_id, + share_scope: { + item: record.share_item, + collection: record.share_collection, + }, + }; + + const accessToken = jwt.sign(tokenPayload, env.SECRET as string, { + expiresIn: env.ACCESS_TOKEN_TTL, + issuer: 'directus', + }); + + const refreshToken = nanoid(64); + const refreshTokenExpiration = new Date(Date.now() + ms(env.REFRESH_TOKEN_TTL as string)); + + await this.knex('directus_sessions').insert({ + token: refreshToken, + expires: refreshTokenExpiration, + ip: this.accountability?.ip, + user_agent: this.accountability?.userAgent, + share: record.share_id, + }); + + await this.knex('directus_sessions').delete().where('expires', '<', new Date()); + + return { + accessToken, + refreshToken, + expires: ms(env.ACCESS_TOKEN_TTL as string), + }; + } + + /** + * Send a link to the given share ID to the given email(s). Note: you can only send a link to a share + * if you have read access to that particular share + */ + async invite(payload: { emails: string[]; share: PrimaryKey }) { + if (!this.accountability?.user) throw new ForbiddenException(); + + const share = await this.readOne(payload.share, { fields: ['collection'] }); + + const usersService = new UsersService({ + knex: this.knex, + schema: this.schema, + }); + + const mailService = new MailService({ schema: this.schema, accountability: this.accountability }); + + const userInfo = await usersService.readOne(this.accountability.user, { + fields: ['first_name', 'last_name', 'email', 'id'], + }); + + const message = ` +Hello! + +${userName(userInfo)} has invited you to view an item in ${share.collection}. + +[Open](${env.PUBLIC_URL}/admin/shared/${payload.share}) +`; + + for (const email of payload.emails) { + await mailService.send({ + template: { + name: 'base', + data: { + html: md(message), + }, + }, + to: email, + subject: `${userName(userInfo)} has shared an item with you`, + }); + } + } +} diff --git a/api/src/services/specifications.ts b/api/src/services/specifications.ts index 268d6434d4..9acf57570e 100644 --- a/api/src/services/specifications.ts +++ b/api/src/services/specifications.ts @@ -454,7 +454,7 @@ class OASSpecsService implements SpecificationSubService { }, ], }; - } else if (relationType === 'm2a') { + } else if (relationType === 'a2o') { const relatedTags = tags.filter((tag) => relation.meta!.one_allowed_collections!.includes(tag['x-collection'])); propertyObject.type = 'array'; diff --git a/api/src/services/users.ts b/api/src/services/users.ts index 5330d2faa1..e8cd34510d 100644 --- a/api/src/services/users.ts +++ b/api/src/services/users.ts @@ -6,13 +6,13 @@ import env from '../env'; import { FailedValidationException } from '@directus/shared/exceptions'; import { ForbiddenException, InvalidPayloadException, UnprocessableEntityException } from '../exceptions'; import { RecordNotUniqueException } from '../exceptions/database/record-not-unique'; -import { AbstractServiceOptions, Item, PrimaryKey, SchemaOverview } from '../types'; +import { AbstractServiceOptions, Item, PrimaryKey, SchemaOverview, MutationOptions } from '../types'; import { Query } from '@directus/shared/types'; import { Accountability } from '@directus/shared/types'; import isUrlAllowed from '../utils/is-url-allowed'; import { toArray } from '@directus/shared/utils'; import { Url } from '../utils/url'; -import { ItemsService, MutationOptions } from './items'; +import { ItemsService } from './items'; import { MailService } from './mail'; import { SettingsService } from './settings'; import { stall } from '../utils/stall'; diff --git a/api/src/services/webhooks.ts b/api/src/services/webhooks.ts index c1b00b11e1..71d4bd539a 100644 --- a/api/src/services/webhooks.ts +++ b/api/src/services/webhooks.ts @@ -1,6 +1,6 @@ -import { AbstractServiceOptions, Item, PrimaryKey, Webhook } from '../types'; +import { AbstractServiceOptions, Item, PrimaryKey, Webhook, MutationOptions } from '../types'; import { register } from '../webhooks'; -import { ItemsService, MutationOptions } from './items'; +import { ItemsService } from './items'; export class WebhooksService extends ItemsService { constructor(options: AbstractServiceOptions) { diff --git a/api/src/types/ast.ts b/api/src/types/ast.ts index f52f972a84..e5237832f0 100644 --- a/api/src/types/ast.ts +++ b/api/src/types/ast.ts @@ -12,8 +12,8 @@ export type M2ONode = { relatedKey: string; }; -export type M2ANode = { - type: 'm2a'; +export type A2MNode = { + type: 'a2o'; names: string[]; children: { [collection: string]: (NestedCollectionNode | FieldNode)[]; @@ -40,7 +40,7 @@ export type O2MNode = { relatedKey: string; }; -export type NestedCollectionNode = M2ONode | O2MNode | M2ANode; +export type NestedCollectionNode = M2ONode | O2MNode | A2MNode; export type FieldNode = { type: 'field'; diff --git a/api/src/types/auth.ts b/api/src/types/auth.ts index 89173a7a6c..09199e1679 100644 --- a/api/src/types/auth.ts +++ b/api/src/types/auth.ts @@ -17,6 +17,8 @@ export interface User { provider: string; external_identifier: string | null; auth_data: string | Record | null; + app_access: boolean; + admin_access: boolean; } export type AuthData = Record | null; @@ -25,6 +27,38 @@ export interface Session { token: string; expires: Date; data: string | Record | null; + share: string; } export type SessionData = Record | null; + +export type DirectusTokenPayload = { + id?: string; + role: string | null; + app_access: boolean | number; + admin_access: boolean | number; + share?: string; + share_scope?: { + collection: string; + item: string; + }; +}; + +export type ShareData = { + share_id: string; + share_role: string; + share_item: string; + share_collection: string; + share_start: Date; + share_end: Date; + share_times_used: number; + share_max_uses?: number; + share_password?: string; +}; + +export type LoginResult = { + accessToken: any; + refreshToken: any; + expires: any; + id?: any; +}; diff --git a/api/src/types/items.ts b/api/src/types/items.ts index 17c9abfcf5..5add72b3f5 100644 --- a/api/src/types/items.ts +++ b/api/src/types/items.ts @@ -16,3 +16,20 @@ export type Alterations = { }[]; delete: (number | string)[]; }; + +export type MutationOptions = { + /** + * Callback function that's fired whenever a revision is made in the mutation + */ + onRevisionCreate?: (pk: PrimaryKey) => void; + + /** + * Flag to disable the auto purging of the cache. Is ignored when CACHE_AUTO_PURGE isn't enabled. + */ + autoPurgeCache?: false; + + /** + * Allow disabling the emitting of hooks. Useful if a custom hook is fired (like files.upload) + */ + emitEvents?: boolean; +}; diff --git a/api/src/utils/apply-query.ts b/api/src/utils/apply-query.ts index a5498bfd81..4a8f0c9df1 100644 --- a/api/src/utils/apply-query.ts +++ b/api/src/utils/apply-query.ts @@ -3,7 +3,7 @@ import { clone, cloneDeep, get, isPlainObject, set } from 'lodash'; import { customAlphabet } from 'nanoid'; import validate from 'uuid-validate'; import { InvalidQueryException } from '../exceptions'; -import { Relation, SchemaOverview } from '../types'; +import { Relation, RelationMeta, SchemaOverview } from '../types'; import { Aggregate, Filter, LogicalFilterAND, Query } from '@directus/shared/types'; import { getColumn } from './get-column'; import { getRelationType } from './get-relation-type'; @@ -133,6 +133,57 @@ export default function applyQuery( * ) * ``` */ +type RelationInfo = { + relation: Relation | null; + relationType: string | null; +}; + +function getRelationInfo(relations: Relation[], collection: string, field: string): RelationInfo { + const implicitRelation = field.match(/^\$FOLLOW\((.*?),(.*?)(?:,(.*?))?\)$/)?.slice(1); + + if (implicitRelation) { + if (implicitRelation[2] === undefined) { + const [m2oCollection, m2oField] = implicitRelation; + + const relation: Relation = { + collection: m2oCollection, + field: m2oField, + related_collection: collection, + schema: null, + meta: null, + }; + + return { relation, relationType: 'o2m' }; + } else { + const [a2oCollection, a2oItemField, a2oCollectionField] = implicitRelation; + + const relation: Relation = { + collection: a2oCollection, + field: a2oItemField, + related_collection: collection, + schema: null, + meta: { + one_collection_field: a2oCollectionField, + one_field: field, + } as RelationMeta, + }; + + return { relation, relationType: 'o2a' }; + } + } + + const relation = + relations.find((relation) => { + return ( + (relation.collection === collection && relation.field === field) || + (relation.related_collection === collection && relation.meta?.one_field === field) + ); + }) ?? null; + + const relationType = relation ? getRelationType({ relation, collection, field }) : null; + + return { relation, relationType }; +} export function applyFilter( knex: Knex, @@ -181,20 +232,15 @@ export function applyFilter( function followRelation(pathParts: string[], parentCollection: string = collection, parentAlias?: string) { /** - * For M2A fields, the path can contain an optional collection scope : + * For A2M fields, the path can contain an optional collection scope : */ const pathRoot = pathParts[0].split(':')[0]; - const relation = relations.find((relation) => { - return ( - (relation.collection === parentCollection && relation.field === pathRoot) || - (relation.related_collection === parentCollection && relation.meta?.one_field === pathRoot) - ); - }); + const { relation, relationType } = getRelationInfo(relations, parentCollection, pathRoot); - if (!relation) return; - - const relationType = getRelationType({ relation, collection: parentCollection, field: pathRoot }); + if (!relation) { + return; + } const alias = generateAlias(); @@ -208,7 +254,7 @@ export function applyFilter( ); } - if (relationType === 'm2a') { + if (relationType === 'a2o') { const pathScope = pathParts[0].split(':')[1]; if (!pathScope) { @@ -219,12 +265,27 @@ export function applyFilter( dbQuery.leftJoin({ [alias]: pathScope }, (joinClause) => { joinClause - .on( + .onVal(relation.meta!.one_collection_field!, '=', pathScope) + .andOn( `${parentAlias || parentCollection}.${relation.field}`, '=', knex.raw(`CAST(?? AS CHAR(255))`, `${alias}.${schema.collections[pathScope].primary}`) - ) - .andOnVal(relation.meta!.one_collection_field!, '=', pathScope); + ); + }); + } + + if (relationType === 'o2a') { + dbQuery.leftJoin({ [alias]: relation.collection }, (joinClause) => { + joinClause + .onVal(relation.meta!.one_collection_field!, '=', parentCollection) + .andOn( + `${alias}.${relation.field}`, + '=', + knex.raw( + `CAST(?? AS CHAR(255))`, + `${parentAlias || parentCollection}.${schema.collections[parentCollection].primary}` + ) + ); }); } @@ -242,7 +303,7 @@ export function applyFilter( if (relationType === 'm2o') { parent = relation.related_collection!; - } else if (relationType === 'm2a') { + } else if (relationType === 'a2o') { const pathScope = pathParts[0].split(':')[1]; if (!pathScope) { @@ -294,22 +355,15 @@ export function applyFilter( const filterPath = getFilterPath(key, value); /** - * For M2A fields, the path can contain an optional collection scope : + * For A2M fields, the path can contain an optional collection scope : */ const pathRoot = filterPath[0].split(':')[0]; - const relation = relations.find((relation) => { - return ( - (relation.collection === collection && relation.field === pathRoot) || - (relation.related_collection === collection && relation.meta?.one_field === pathRoot) - ); - }); + const { relation, relationType } = getRelationInfo(relations, collection, pathRoot); const { operator: filterOperator, value: filterValue } = getOperation(key, value); - const relationType = relation ? getRelationType({ relation, collection: collection, field: pathRoot }) : null; - - if (relationType === 'm2o' || relationType === 'm2a' || relationType === null) { + if (relationType === 'm2o' || relationType === 'a2o' || relationType === null) { if (filterPath.length > 1) { const columnName = getWhereColumn(filterPath, collection); if (!columnName) continue; @@ -318,12 +372,22 @@ export function applyFilter( applyFilterToQuery(`${collection}.${filterPath[0]}`, filterOperator, filterValue, logical); } } else if (subQuery === false) { - const pkField = `${collection}.${schema.collections[relation!.related_collection!].primary}`; + if (!relation) continue; - dbQuery[logical].whereIn(pkField, (subQueryKnex) => { + let pkField: Knex.Raw | string = `${collection}.${ + schema.collections[relation!.related_collection!].primary + }`; + + if (relationType === 'o2a') { + pkField = knex.raw(`CAST(?? AS CHAR(255))`, [pkField]); + } + + // Note: knex's types don't appreciate knex.raw in whereIn, even though it's officially supported + dbQuery[logical].whereIn(pkField as string, (subQueryKnex) => { const field = relation!.field; const collection = relation!.collection; const column = `${collection}.${field}`; + subQueryKnex.select({ [field]: column }).from(collection); applyQuery( @@ -501,30 +565,23 @@ export function applyFilter( parentAlias?: string ): string | void { /** - * For M2A fields, the path can contain an optional collection scope : + * For A2M fields, the path can contain an optional collection scope : */ const pathRoot = pathParts[0].split(':')[0]; - const relation = relations.find((relation) => { - return ( - (relation.collection === parentCollection && relation.field === pathRoot) || - (relation.related_collection === parentCollection && relation.meta?.one_field === pathRoot) - ); - }); + const { relation, relationType } = getRelationInfo(relations, parentCollection, pathRoot); if (!relation) { throw new InvalidQueryException(`"${parentCollection}.${pathRoot}" is not a relational field`); } - const relationType = getRelationType({ relation, collection: parentCollection, field: pathRoot }); - const alias = get(aliasMap, parentAlias ? [parentAlias, ...pathParts] : pathParts); const remainingParts = pathParts.slice(1); let parent: string; - if (relationType === 'm2a') { + if (relationType === 'a2o') { const pathScope = pathParts[0].split(':')[1]; if (!pathScope) { diff --git a/api/src/utils/get-ast-from-query.ts b/api/src/utils/get-ast-from-query.ts index 7e1084a327..4b897b95b2 100644 --- a/api/src/utils/get-ast-from-query.ts +++ b/api/src/utils/get-ast-from-query.ts @@ -136,7 +136,7 @@ export default async function getASTFromQuery( let rootField = parts[0]; let collectionScope: string | null = null; - // m2a related collection scoped field selector `fields=sections.section_id:headings.title` + // a2o related collection scoped field selector `fields=sections.section_id:headings.title` if (rootField.includes(':')) { const [key, scope] = rootField.split(':'); rootField = key; @@ -191,14 +191,14 @@ export default async function getASTFromQuery( let child: NestedCollectionNode | null = null; - if (relationType === 'm2a') { + if (relationType === 'a2o') { const allowedCollections = relation.meta!.one_allowed_collections!.filter((collection) => { if (!permissions) return true; return permissions.some((permission) => permission.collection === collection); }); child = { - type: 'm2a', + type: 'a2o', names: allowedCollections, children: {}, query: {}, diff --git a/api/src/utils/get-permissions.ts b/api/src/utils/get-permissions.ts index 5dadd4fbc8..b6eb8ec547 100644 --- a/api/src/utils/get-permissions.ts +++ b/api/src/utils/get-permissions.ts @@ -4,6 +4,7 @@ import { cloneDeep } from 'lodash'; import getDatabase from '../database'; import { appAccessMinimalPermissions } from '../database/system-data/app-access-permissions'; import { mergePermissions } from '../utils/merge-permissions'; +import { mergePermissionsForShare } from './merge-permissions-for-share'; import { UsersService } from '../services/users'; import { RolesService } from '../services/roles'; import { getCache } from '../cache'; @@ -17,8 +18,8 @@ export async function getPermissions(accountability: Accountability, schema: Sch let permissions: Permission[] = []; - const { user, role, app, admin } = accountability; - const cacheKey = `permissions-${hash({ user, role, app, admin })}`; + const { user, role, app, admin, share_scope } = accountability; + const cacheKey = `permissions-${hash({ user, role, app, admin, share_scope })}`; if (env.CACHE_PERMISSIONS !== false) { const cachedPermissions = await systemCache.get(cacheKey); @@ -57,10 +58,15 @@ export async function getPermissions(accountability: Accountability, schema: Sch } if (accountability.admin !== true) { - const permissionsForRole = await database - .select('*') - .from('directus_permissions') - .where({ role: accountability.role }); + const query = database.select('*').from('directus_permissions'); + + if (accountability.role) { + query.where({ role: accountability.role }); + } else { + query.whereNull('role'); + } + + const permissionsForRole = await query; const { permissions: parsedPermissions, @@ -72,11 +78,16 @@ export async function getPermissions(accountability: Accountability, schema: Sch if (accountability.app === true) { permissions = mergePermissions( + 'or', permissions, - appAccessMinimalPermissions.map((perm) => ({ ...perm, role: accountability!.role })) + appAccessMinimalPermissions.map((perm) => ({ ...perm, role: accountability.role })) ); } + if (accountability.share_scope) { + permissions = mergePermissionsForShare(permissions, accountability, schema); + } + const filterContext = containDynamicData ? await getFilterContext(schema, accountability, requiredPermissionData) : {}; diff --git a/api/src/utils/get-relation-type.ts b/api/src/utils/get-relation-type.ts index 9f5679b1ff..6ade2b4f34 100644 --- a/api/src/utils/get-relation-type.ts +++ b/api/src/utils/get-relation-type.ts @@ -4,7 +4,7 @@ export function getRelationType(getRelationOptions: { relation: Relation; collection: string | null; field: string; -}): 'm2o' | 'o2m' | 'm2a' | null { +}): 'm2o' | 'o2m' | 'a2o' | null { const { relation, collection, field } = getRelationOptions; if (!relation) return null; @@ -15,7 +15,7 @@ export function getRelationType(getRelationOptions: { relation.meta?.one_collection_field && relation.meta?.one_allowed_collections ) { - return 'm2a'; + return 'a2o'; } if (relation.collection === collection && relation.field === field) { diff --git a/api/src/utils/merge-permissions-for-share.ts b/api/src/utils/merge-permissions-for-share.ts new file mode 100644 index 0000000000..262a1ea3ed --- /dev/null +++ b/api/src/utils/merge-permissions-for-share.ts @@ -0,0 +1,180 @@ +import { Permission, Accountability, Filter } from '@directus/shared/types'; +import { SchemaOverview } from '../types'; +import { assign, set, uniq } from 'lodash'; +import { mergePermissions } from './merge-permissions'; +import { schemaPermissions } from '../database/system-data/app-access-permissions'; +import { reduceSchema } from './reduce-schema'; + +export function mergePermissionsForShare( + currentPermissions: Permission[], + accountability: Accountability, + schema: SchemaOverview +): Permission[] { + const defaults: Permission = { + action: 'read', + role: accountability.role, + collection: '', + permissions: {}, + validation: null, + presets: null, + fields: null, + }; + + const { collection, item } = accountability.share_scope!; + + const parentPrimaryKeyField = schema.collections[collection].primary; + + const reducedSchema = reduceSchema(schema, currentPermissions, ['read']); + + const relationalPermissions = traverse(reducedSchema, parentPrimaryKeyField, item, collection); + + const parentCollectionPermission: Permission = assign({}, defaults, { + collection, + permissions: { + [parentPrimaryKeyField]: { + _eq: item, + }, + }, + }); + + // All permissions that will be merged into the original permissions set + const allGeneratedPermissions = [ + parentCollectionPermission, + ...relationalPermissions.map((generated) => assign({}, defaults, generated)), + ...schemaPermissions, + ]; + + // All the collections that are touched through the relational tree from the current root collection, and the schema collections + const allowedCollections = uniq(allGeneratedPermissions.map(({ collection }) => collection)); + + const generatedPermissions: Permission[] = []; + + // Merge all the permissions that relate to the same collection with an _or (this allows you to properly retrieve) + // the items of a collection if you entered that collection from multiple angles + for (const collection of allowedCollections) { + const permissionsForCollection = allGeneratedPermissions.filter( + (permission) => permission.collection === collection + ); + + if (permissionsForCollection.length > 0) { + generatedPermissions.push(...mergePermissions('or', permissionsForCollection)); + } else { + generatedPermissions.push(...permissionsForCollection); + } + } + + // Explicitly filter out permissions to collections unrelated to the root parent item. + const limitedPermissions = currentPermissions.filter(({ collection }) => allowedCollections.includes(collection)); + + return mergePermissions('and', limitedPermissions, generatedPermissions); +} + +export function traverse( + schema: SchemaOverview, + rootItemPrimaryKeyField: string, + rootItemPrimaryKey: string, + currentCollection: string, + parentCollections: string[] = [], + path: string[] = [] +): Partial[] { + const permissions: Partial[] = []; + + // If there's already a permissions rule for the collection we're currently checking, we'll shortcircuit. + // This prevents infinite loop in recursive relationships, like articles->related_articles->articles, or + // articles.author->users.avatar->files.created_by->users.avatar->files.created_by->🔁 + if (parentCollections.includes(currentCollection)) { + return permissions; + } + + const relationsInCollection = schema.relations.filter((relation) => { + return relation.collection === currentCollection || relation.related_collection === currentCollection; + }); + + for (const relation of relationsInCollection) { + let type; + + if (relation.related_collection === currentCollection) { + type = 'o2m'; + } else if (!relation.related_collection) { + type = 'a2o'; + } else { + type = 'm2o'; + } + + if (type === 'o2m') { + permissions.push({ + collection: relation.collection, + permissions: getFilterForPath(type, [...path, relation.field], rootItemPrimaryKeyField, rootItemPrimaryKey), + }); + + permissions.push( + ...traverse( + schema, + rootItemPrimaryKeyField, + rootItemPrimaryKey, + relation.collection, + [...parentCollections, currentCollection], + [...path, relation.field] + ) + ); + } + + if (type === 'a2o' && relation.meta?.one_allowed_collections) { + for (const collection of relation.meta.one_allowed_collections) { + permissions.push({ + collection, + permissions: getFilterForPath( + type, + [...path, `$FOLLOW(${relation.collection},${relation.field},${relation.meta.one_collection_field})`], + rootItemPrimaryKeyField, + rootItemPrimaryKey + ), + }); + } + } + + if (type === 'm2o') { + permissions.push({ + collection: relation.related_collection!, + permissions: getFilterForPath( + type, + [...path, `$FOLLOW(${relation.collection},${relation.field})`], + rootItemPrimaryKeyField, + rootItemPrimaryKey + ), + }); + + if (relation.meta?.one_field) { + permissions.push( + ...traverse( + schema, + rootItemPrimaryKeyField, + rootItemPrimaryKey, + relation.related_collection!, + [...parentCollections, currentCollection], + [...path, relation.meta?.one_field] + ) + ); + } + } + } + + return permissions; +} + +export function getFilterForPath( + type: 'o2m' | 'm2o' | 'a2o', + path: string[], + rootPrimaryKeyField: string, + rootPrimaryKey: string +): Filter { + const filter: Filter = {}; + + if (type === 'm2o' || type === 'a2o') { + set(filter, path.reverse(), { [rootPrimaryKeyField]: { _eq: rootPrimaryKey } }); + } else { + set(filter, path.reverse(), { _eq: rootPrimaryKey }); + } + + return filter; +} diff --git a/api/src/utils/merge-permissions.ts b/api/src/utils/merge-permissions.ts index 48c82b6cc5..cce549375e 100644 --- a/api/src/utils/merge-permissions.ts +++ b/api/src/utils/merge-permissions.ts @@ -1,66 +1,72 @@ -import { flatten, merge, omit } from 'lodash'; -import { Permission, LogicalFilterOR } from '@directus/shared/types'; +import { flatten, merge, omit, intersection } from 'lodash'; +import { Permission, LogicalFilterOR, LogicalFilterAND } from '@directus/shared/types'; -export function mergePermissions(...permissions: Permission[][]): Permission[] { +export function mergePermissions(strategy: 'and' | 'or', ...permissions: Permission[][]): Permission[] { const allPermissions = flatten(permissions); const mergedPermissions = allPermissions .reduce((acc, val) => { const key = `${val.collection}__${val.action}__${val.role || '$PUBLIC'}`; const current = acc.get(key); - acc.set(key, current ? mergePerm(current, val) : val); + acc.set(key, current ? mergePermission(strategy, current, val) : val); return acc; }, new Map()) .values(); - const result = Array.from(mergedPermissions).map((perm) => { - return omit(perm, ['id', 'system']) as Permission; - }); - - return result; + return Array.from(mergedPermissions); } -function mergePerm(currentPerm: Permission, newPerm: Permission) { +export function mergePermission(strategy: 'and' | 'or', currentPerm: Permission, newPerm: Permission) { + const logicalKey = `_${strategy}` as keyof LogicalFilterOR | keyof LogicalFilterAND; + let permissions = currentPerm.permissions; let validation = currentPerm.validation; let fields = currentPerm.fields; let presets = currentPerm.presets; if (newPerm.permissions) { - if (currentPerm.permissions && Object.keys(currentPerm.permissions)[0] === '_or') { + if (currentPerm.permissions && Object.keys(currentPerm.permissions)[0] === logicalKey) { permissions = { - _or: [...(currentPerm.permissions as LogicalFilterOR)._or, newPerm.permissions], - }; + [logicalKey]: [ + ...(currentPerm.permissions as LogicalFilterOR & LogicalFilterAND)[logicalKey], + newPerm.permissions, + ], + } as LogicalFilterAND | LogicalFilterOR; } else if (currentPerm.permissions) { permissions = { - _or: [currentPerm.permissions, newPerm.permissions], - }; + [logicalKey]: [currentPerm.permissions, newPerm.permissions], + } as LogicalFilterAND | LogicalFilterOR; } else { permissions = { - _or: [newPerm.permissions], - }; + [logicalKey]: [newPerm.permissions], + } as LogicalFilterAND | LogicalFilterOR; } } if (newPerm.validation) { - if (currentPerm.validation && Object.keys(currentPerm.validation)[0] === '_or') { + if (currentPerm.validation && Object.keys(currentPerm.validation)[0] === logicalKey) { validation = { - _or: [...(currentPerm.validation as LogicalFilterOR)._or, newPerm.validation], - }; + [logicalKey]: [ + ...(currentPerm.validation as LogicalFilterOR & LogicalFilterAND)[logicalKey], + newPerm.validation, + ], + } as LogicalFilterAND | LogicalFilterOR; } else if (currentPerm.validation) { validation = { - _or: [currentPerm.validation, newPerm.validation], - }; + [logicalKey]: [currentPerm.validation, newPerm.validation], + } as LogicalFilterAND | LogicalFilterOR; } else { validation = { - _or: [newPerm.validation], - }; + [logicalKey]: [newPerm.validation], + } as LogicalFilterAND | LogicalFilterOR; } } if (newPerm.fields) { - if (Array.isArray(currentPerm.fields)) { + if (Array.isArray(currentPerm.fields) && strategy === 'or') { fields = [...new Set([...currentPerm.fields, ...newPerm.fields])]; + } else if (Array.isArray(currentPerm.fields) && strategy === 'and') { + fields = intersection(currentPerm.fields, newPerm.fields); } else { fields = newPerm.fields; } @@ -72,11 +78,14 @@ function mergePerm(currentPerm: Permission, newPerm: Permission) { presets = merge({}, presets, newPerm.presets); } - return { - ...currentPerm, - permissions, - validation, - fields, - presets, - }; + return omit( + { + ...currentPerm, + permissions, + validation, + fields, + presets, + }, + ['id', 'system'] + ); } diff --git a/api/src/utils/reduce-schema.ts b/api/src/utils/reduce-schema.ts index dc46472d0f..88906f6ce3 100644 --- a/api/src/utils/reduce-schema.ts +++ b/api/src/utils/reduce-schema.ts @@ -1,6 +1,6 @@ import { uniq } from 'lodash'; import { SchemaOverview } from '../types'; -import { Accountability, PermissionsAction } from '@directus/shared/types'; +import { Permission, PermissionsAction } from '@directus/shared/types'; /** * Reduces the schema based on the included permissions. The resulting object is the schema structure, but with only @@ -11,7 +11,7 @@ import { Accountability, PermissionsAction } from '@directus/shared/types'; */ export function reduceSchema( schema: SchemaOverview, - accountability: Accountability | null, + permissions: Permission[] | null, actions: PermissionsAction[] = ['create', 'read', 'update', 'delete'] ): SchemaOverview { const reduced: SchemaOverview = { @@ -20,7 +20,7 @@ export function reduceSchema( }; const allowedFieldsInCollection = - accountability?.permissions + permissions ?.filter((permission) => actions.includes(permission.action)) .reduce((acc, permission) => { if (!acc[permission.collection]) { @@ -36,9 +36,7 @@ export function reduceSchema( for (const [collectionName, collection] of Object.entries(schema.collections)) { if ( - accountability?.permissions?.some( - (permission) => permission.collection === collectionName && actions.includes(permission.action) - ) + permissions?.some((permission) => permission.collection === collectionName && actions.includes(permission.action)) ) { const fields: SchemaOverview['collections'][string]['fields'] = {}; diff --git a/api/src/utils/user-name.ts b/api/src/utils/user-name.ts index f9cbbd83f2..d0ab2f6e0d 100644 --- a/api/src/utils/user-name.ts +++ b/api/src/utils/user-name.ts @@ -1,6 +1,10 @@ import { User } from '@directus/shared/types'; export function userName(user: Partial): string { + if (!user) { + return 'Unknown User'; + } + if (user.first_name && user.last_name) { return `${user.first_name} ${user.last_name}`; } diff --git a/app/src/auth.ts b/app/src/auth.ts index c590ce8835..598079db39 100644 --- a/app/src/auth.ts +++ b/app/src/auth.ts @@ -6,17 +6,30 @@ import { RouteLocationRaw } from 'vue-router'; import { idleTracker } from './idle'; import { DEFAULT_AUTH_PROVIDER } from '@/constants'; -export type LoginCredentials = { +type LoginCredentials = { identifier?: string; email?: string; - password: string; + password?: string; otp?: string; + share?: string; }; -export async function login(credentials: LoginCredentials, provider: string): Promise { +type LoginParams = { + credentials: LoginCredentials; + provider?: string; + share?: boolean; +}; + +function getAuthEndpoint(provider?: string, share?: boolean) { + if (share) return '/shares/auth'; + if (provider === DEFAULT_AUTH_PROVIDER) return '/auth/login'; + return `/auth/login/${provider}`; +} + +export async function login({ credentials, provider, share }: LoginParams): Promise { const appStore = useAppStore(); - const response = await api.post(provider !== DEFAULT_AUTH_PROVIDER ? `/auth/login/${provider}` : '/auth/login', { + const response = await api.post(getAuthEndpoint(provider, share), { ...credentials, mode: 'cookie', }); @@ -27,7 +40,7 @@ export async function login(credentials: LoginCredentials, provider: string): Pr api.defaults.headers.common['Authorization'] = `Bearer ${accessToken}`; // Refresh the token 10 seconds before the access token expires. This means the user will stay - // logged in without any noticable hickups or delays + // logged in without any noticeable hiccups or delays // setTimeout breaks with numbers bigger than 32bits. This ensures that we don't try refreshing // for tokens that last > 24 days. Ref #4054 diff --git a/app/src/composables/use-permissions.ts b/app/src/composables/use-permissions.ts index 2f55565d34..61cacd70c1 100644 --- a/app/src/composables/use-permissions.ts +++ b/app/src/composables/use-permissions.ts @@ -10,6 +10,7 @@ type UsablePermissions = { saveAllowed: ComputedRef; archiveAllowed: ComputedRef; updateAllowed: ComputedRef; + shareAllowed: ComputedRef; fields: ComputedRef; revisionsAllowed: ComputedRef; }; @@ -32,6 +33,8 @@ export function usePermissions(collection: Ref, item: Ref, isNew: R const updateAllowed = computed(() => isAllowed(collection.value, 'update', item.value)); + const shareAllowed = computed(() => isAllowed(collection.value, 'share', item.value)); + const archiveAllowed = computed(() => { if (!collectionInfo.value?.meta?.archive_field) return false; @@ -90,5 +93,5 @@ export function usePermissions(collection: Ref, item: Ref, isNew: R ); }); - return { deleteAllowed, saveAllowed, archiveAllowed, updateAllowed, fields, revisionsAllowed }; + return { deleteAllowed, saveAllowed, archiveAllowed, updateAllowed, shareAllowed, fields, revisionsAllowed }; } diff --git a/app/src/hydrate.ts b/app/src/hydrate.ts index a49cfa9cf5..73ff0cb0f5 100644 --- a/app/src/hydrate.ts +++ b/app/src/hydrate.ts @@ -44,7 +44,9 @@ export function useStores( return stores.map((useStore) => useStore()) as GenericStore[]; } -export async function hydrate(stores = useStores()): Promise { +export async function hydrate(): Promise { + const stores = useStores(); + const appStore = useAppStore(); const userStore = useUserStore(); const permissionsStore = usePermissionsStore(); diff --git a/app/src/interfaces/file/file.vue b/app/src/interfaces/file/file.vue index 1d239475ac..386be68e2e 100644 --- a/app/src/interfaces/file/file.vue +++ b/app/src/interfaces/file/file.vue @@ -8,6 +8,7 @@ v-else clickable readonly + :disabled="disabled" :placeholder="t('no_file_selected')" :model-value="file && file.title" @click="toggle" @@ -84,6 +85,7 @@ collection="directus_files" :primary-key="file.id" :edits="edits" + :disabled="disabled" @input="stageEdits" /> diff --git a/app/src/interfaces/list-m2a/list-m2a.vue b/app/src/interfaces/list-m2a/list-m2a.vue index aff53feaf4..6b0baed10e 100644 --- a/app/src/interfaces/list-m2a/list-m2a.vue +++ b/app/src/interfaces/list-m2a/list-m2a.vue @@ -68,7 +68,7 @@ -
+
@@ -202,6 +208,7 @@ import ContentNotFound from './not-found.vue'; import { useCollection } from '@directus/shared/composables'; import RevisionsDrawerDetail from '@/views/private/components/revisions-drawer-detail'; import CommentsSidebarDetail from '@/views/private/components/comments-sidebar-detail'; +import SharesSidebarDetail from '@/views/private/components/shares-sidebar-detail'; import useItem from '@/composables/use-item'; import SaveOptions from '@/views/private/components/save-options'; import useShortcut from '@/composables/use-shortcut'; @@ -219,6 +226,7 @@ export default defineComponent({ ContentNotFound, RevisionsDrawerDetail, CommentsSidebarDetail, + SharesSidebarDetail, SaveOptions, }, props: { @@ -352,11 +360,8 @@ export default defineComponent({ onBeforeRouteUpdate(editsGuard); onBeforeRouteLeave(editsGuard); - const { deleteAllowed, archiveAllowed, saveAllowed, updateAllowed, fields, revisionsAllowed } = usePermissions( - collection, - item, - isNew - ); + const { deleteAllowed, archiveAllowed, saveAllowed, updateAllowed, shareAllowed, fields, revisionsAllowed } = + usePermissions(collection, item, isNew); const internalPrimaryKey = computed(() => { if (isNew.value) return '+'; @@ -403,6 +408,7 @@ export default defineComponent({ archiveAllowed, isArchived, updateAllowed, + shareAllowed, toggleArchive, validationErrors, form, diff --git a/app/src/modules/settings/routes/roles/app-permissions.ts b/app/src/modules/settings/routes/roles/app-permissions.ts index c9d531afe0..5d779fa770 100644 --- a/app/src/modules/settings/routes/roles/app-permissions.ts +++ b/app/src/modules/settings/routes/roles/app-permissions.ts @@ -128,6 +128,51 @@ export const appRecommendedPermissions: Partial[] = [ permissions: {}, fields: ['*'], }, + { + collection: 'directus_shares', + action: 'read', + permissions: { + _or: [ + { + role: { + _eq: '$CURRENT_ROLE', + }, + }, + { + role: { + _null: true, + }, + }, + ], + }, + fields: ['*'], + }, + { + collection: 'directus_shares', + action: 'create', + permissions: {}, + fields: ['*'], + }, + { + collection: 'directus_shares', + action: 'update', + permissions: { + user_created: { + _eq: '$CURRENT_USER', + }, + }, + fields: ['*'], + }, + { + collection: 'directus_shares', + action: 'delete', + permissions: { + user_created: { + _eq: '$CURRENT_USER', + }, + }, + fields: ['*'], + }, ]; export const appMinimalPermissions: Partial[] = [ diff --git a/app/src/modules/settings/routes/roles/item/components/permissions-overview-header.vue b/app/src/modules/settings/routes/roles/item/components/permissions-overview-header.vue index abf59ee2a6..21e96cf36c 100644 --- a/app/src/modules/settings/routes/roles/item/components/permissions-overview-header.vue +++ b/app/src/modules/settings/routes/roles/item/components/permissions-overview-header.vue @@ -5,6 +5,7 @@ +
diff --git a/app/src/modules/settings/routes/roles/item/components/permissions-overview-row.vue b/app/src/modules/settings/routes/roles/item/components/permissions-overview-row.vue index b8b4f48250..f4a4680008 100644 --- a/app/src/modules/settings/routes/roles/item/components/permissions-overview-row.vue +++ b/app/src/modules/settings/routes/roles/item/components/permissions-overview-row.vue @@ -41,6 +41,14 @@ :loading="isLoading('delete')" :app-minimal="appMinimal && appMinimal.find((p) => p.action === 'delete')" /> +
diff --git a/app/src/modules/settings/routes/roles/item/composables/use-update-permissions.ts b/app/src/modules/settings/routes/roles/item/composables/use-update-permissions.ts index cbacb78166..2ad78c5965 100644 --- a/app/src/modules/settings/routes/roles/item/composables/use-update-permissions.ts +++ b/app/src/modules/settings/routes/roles/item/composables/use-update-permissions.ts @@ -3,10 +3,13 @@ import { Permission, Collection } from '@directus/shared/types'; import { unexpectedError } from '@/utils/unexpected-error'; import { inject, ref, Ref } from 'vue'; +const ACTIONS = ['create', 'read', 'update', 'delete', 'share'] as const; +type Action = typeof ACTIONS[number]; + type UsableUpdatePermissions = { getPermission: (action: string) => Permission | undefined; - setFullAccess: (action: 'create' | 'read' | 'update' | 'delete') => Promise; - setNoAccess: (action: 'create' | 'read' | 'update' | 'delete') => Promise; + setFullAccess: (action: Action) => Promise; + setNoAccess: (action: Action) => Promise; setFullAccessAll: () => Promise; setNoAccessAll: () => Promise; }; @@ -25,7 +28,7 @@ export default function useUpdatePermissions( return permissions.value.find((permission) => permission.action === action); } - async function setFullAccess(action: 'create' | 'read' | 'update' | 'delete') { + async function setFullAccess(action: Action) { if (saving.value === true) return; saving.value = true; @@ -72,7 +75,7 @@ export default function useUpdatePermissions( } } - async function setNoAccess(action: 'create' | 'read' | 'update' | 'delete') { + async function setNoAccess(action: Action) { if (saving.value === true) return; const permission = getPermission(action); @@ -104,10 +107,8 @@ export default function useUpdatePermissions( }); } - const actions = ['create', 'read', 'update', 'delete']; - await Promise.all( - actions.map(async (action) => { + ACTIONS.map(async (action) => { const permission = getPermission(action); if (permission) { try { diff --git a/app/src/modules/settings/routes/roles/permissions-detail/permissions-detail.vue b/app/src/modules/settings/routes/roles/permissions-detail/permissions-detail.vue index 9d37bade2a..66bb9d4795 100644 --- a/app/src/modules/settings/routes/roles/permissions-detail/permissions-detail.vue +++ b/app/src/modules/settings/routes/roles/permissions-detail/permissions-detail.vue @@ -114,7 +114,7 @@ export default defineComponent({ const tabs = []; - if (['read', 'update', 'delete'].includes(action)) { + if (['read', 'update', 'delete', 'share'].includes(action)) { tabs.push({ text: t('item_permissions'), value: 'permissions', diff --git a/app/src/router.ts b/app/src/router.ts index 62297f3088..7221842d48 100644 --- a/app/src/router.ts +++ b/app/src/router.ts @@ -3,6 +3,7 @@ import { hydrate } from '@/hydrate'; import AcceptInviteRoute from '@/routes/accept-invite'; import LoginRoute from '@/routes/login'; import LogoutRoute from '@/routes/logout'; +import ShareRoute from '@/routes/shared'; import PrivateNotFoundRoute from '@/routes/private-not-found'; import ResetPasswordRoute from '@/routes/reset-password'; import { useAppStore, useServerStore, useUserStore } from '@/stores'; @@ -50,6 +51,14 @@ export const defaultRoutes: RouteRecordRaw[] = [ public: true, }, }, + { + name: 'shared', + path: '/shared/:id([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})', + component: ShareRoute, + meta: { + public: true, + }, + }, { name: 'private-404', path: '/:_(.+)+', diff --git a/app/src/routes/login/components/continue-as.vue b/app/src/routes/login/components/continue-as.vue index 7dc737d338..38bed0d5f0 100644 --- a/app/src/routes/login/components/continue-as.vue +++ b/app/src/routes/login/components/continue-as.vue @@ -24,6 +24,7 @@ import { hydrate } from '@/hydrate'; import { useRouter } from 'vue-router'; import { userName } from '@/utils/user-name'; import { unexpectedError } from '@/utils/unexpected-error'; +import { logout } from '@/auth'; export default defineComponent({ setup() { @@ -55,6 +56,10 @@ export default defineComponent({ }, }); + if (response.data.data.share) { + await logout(); + } + name.value = userName(response.data.data); lastPage.value = response.data.data.last_page; } catch (err: any) { diff --git a/app/src/routes/login/components/login-form/ldap-form.vue b/app/src/routes/login/components/login-form/ldap-form.vue index 62e9030421..9a1d9dd27c 100644 --- a/app/src/routes/login/components/login-form/ldap-form.vue +++ b/app/src/routes/login/components/login-form/ldap-form.vue @@ -101,7 +101,7 @@ export default defineComponent({ credentials.otp = otp.value; } - await login(credentials, provider.value); + await login({ provider: provider.value, credentials }); // Stores are hydrated after login const lastPage = userStore.currentUser?.last_page; diff --git a/app/src/routes/login/components/login-form/login-form.vue b/app/src/routes/login/components/login-form/login-form.vue index f1787913e2..b8d619ea54 100644 --- a/app/src/routes/login/components/login-form/login-form.vue +++ b/app/src/routes/login/components/login-form/login-form.vue @@ -107,7 +107,7 @@ export default defineComponent({ credentials.otp = otp.value; } - await login(credentials, provider.value); + await login({ provider: provider.value, credentials }); const redirectQuery = router.currentRoute.value.query.redirect as string; diff --git a/app/src/routes/login/login.vue b/app/src/routes/login/login.vue index 2150223d27..44a1741ab6 100644 --- a/app/src/routes/login/login.vue +++ b/app/src/routes/login/login.vue @@ -55,9 +55,9 @@ export default defineComponent({ const appStore = useAppStore(); - const providers = ref([]); + const providers = ref<{ driver: string; name: string }[]>([]); const provider = ref(DEFAULT_AUTH_PROVIDER); - const providerOptions = ref([]); + const providerOptions = ref<{ text: string; value: string }[]>([]); const driver = ref('local'); const providerSelect = computed({ diff --git a/app/src/routes/shared/components/share-item.vue b/app/src/routes/shared/components/share-item.vue new file mode 100644 index 0000000000..e4e36d2b5d --- /dev/null +++ b/app/src/routes/shared/components/share-item.vue @@ -0,0 +1,36 @@ + + + diff --git a/app/src/routes/shared/index.ts b/app/src/routes/shared/index.ts new file mode 100644 index 0000000000..9987effb22 --- /dev/null +++ b/app/src/routes/shared/index.ts @@ -0,0 +1,4 @@ +import SharedRoute from './shared.vue'; + +export { SharedRoute }; +export default SharedRoute; diff --git a/app/src/routes/shared/shared.vue b/app/src/routes/shared/shared.vue new file mode 100644 index 0000000000..51a6166621 --- /dev/null +++ b/app/src/routes/shared/shared.vue @@ -0,0 +1,235 @@ + + + + + diff --git a/app/src/stores/notifications.ts b/app/src/stores/notifications.ts index 029e834699..765a5e69c8 100644 --- a/app/src/stores/notifications.ts +++ b/app/src/stores/notifications.ts @@ -17,18 +17,24 @@ export const useNotificationsStore = defineStore({ }), actions: { async hydrate() { - await this.getUnreadCount(); + const userStore = useUserStore(); + + if (userStore.currentUser && !('share' in userStore.currentUser)) { + await this.getUnreadCount(); + } }, async getUnreadCount() { const userStore = useUserStore(); + if (!userStore.currentUser || !('id' in userStore.currentUser)) return; + const countResponse = await api.get('/notifications', { params: { filter: { _and: [ { recipient: { - _eq: userStore.currentUser!.id, + _eq: userStore.currentUser.id, }, }, { diff --git a/app/src/stores/presets.ts b/app/src/stores/presets.ts index 2e9696a9a1..88146aeb50 100644 --- a/app/src/stores/presets.ts +++ b/app/src/stores/presets.ts @@ -127,8 +127,11 @@ export const usePresetsStore = defineStore({ }, actions: { async hydrate() { + const userStore = useUserStore(); + if (!userStore.currentUser || 'share' in userStore.currentUser) return; + // Hydrate is only called for logged in users, therefore, currentUser exists - const { id, role } = useUserStore().currentUser!; + const { id, role } = userStore.currentUser; const values = await Promise.all([ // All user saved bookmarks and presets diff --git a/app/src/stores/settings.ts b/app/src/stores/settings.ts index c20e75e7b9..4a77b43234 100644 --- a/app/src/stores/settings.ts +++ b/app/src/stores/settings.ts @@ -5,6 +5,7 @@ import { unexpectedError } from '@/utils/unexpected-error'; import { merge } from 'lodash'; import { defineStore } from 'pinia'; import { Settings } from '@directus/shared/types'; +import { useUserStore } from './user'; export const useSettingsStore = defineStore({ id: 'settingsStore', @@ -13,6 +14,9 @@ export const useSettingsStore = defineStore({ }), actions: { async hydrate() { + const userStore = useUserStore(); + if (!userStore.currentUser || 'share' in userStore.currentUser) return; + const response = await api.get(`/settings`); this.settings = response.data.data; }, diff --git a/app/src/stores/user.ts b/app/src/stores/user.ts index 1b8150f791..44afe95c95 100644 --- a/app/src/stores/user.ts +++ b/app/src/stores/user.ts @@ -4,16 +4,25 @@ import { User } from '@directus/shared/types'; import { userName } from '@/utils/user-name'; import { defineStore } from 'pinia'; +type ShareUser = { + share: string; + role: { + id: string; + admin_access: false; + app_access: false; + }; +}; + export const useUserStore = defineStore({ id: 'userStore', state: () => ({ - currentUser: null as User | null, + currentUser: null as User | ShareUser | null, loading: false, error: null, }), getters: { fullName(): string | null { - if (this.currentUser === null) return null; + if (this.currentUser === null || 'share' in this.currentUser) return null; return userName(this.currentUser); }, isAdmin(): boolean { @@ -25,11 +34,18 @@ export const useUserStore = defineStore({ this.loading = true; try { - const { data } = await api.get(`/users/me`, { - params: { - fields: '*,avatar.id,role.*', - }, - }); + const fields = [ + 'id', + 'language', + 'last_page', + 'theme', + 'avatar.id', + 'role.admin_access', + 'role.app_access', + 'role.id', + ]; + + const { data } = await api.get(`/users/me`, { params: { fields } }); this.currentUser = data.data; } catch (error: any) { @@ -57,7 +73,7 @@ export const useUserStore = defineStore({ latency: end - start, }); - if (this.currentUser) { + if (this.currentUser && !('share' in this.currentUser)) { this.currentUser.last_page = page; } }, diff --git a/app/src/types/index.ts b/app/src/types/index.ts index 8b22a63622..7d7fa99513 100644 --- a/app/src/types/index.ts +++ b/app/src/types/index.ts @@ -3,3 +3,4 @@ export * from './error'; export * from './insights'; export * from './notifications'; export * from './login'; +export * from './shares'; diff --git a/app/src/utils/is-allowed.ts b/app/src/utils/is-allowed.ts index aa99c1d6a5..97266d14a1 100644 --- a/app/src/utils/is-allowed.ts +++ b/app/src/utils/is-allowed.ts @@ -20,13 +20,13 @@ export function isAllowed( ); if (!permissionInfo) return false; - if (!permissionInfo.fields) return false; + if (!permissionInfo.fields && action !== 'share') return false; - if (strict && permissionInfo.fields.includes('*') === false && value) { + if (strict && action !== 'share' && permissionInfo.fields!.includes('*') === false && value) { const allowedFields = permissionInfo.fields; const attemptedFields = Object.keys(value); - if (attemptedFields.every((field) => allowedFields.includes(field)) === false) return false; + if (attemptedFields.every((field) => allowedFields!.includes(field)) === false) return false; } const schema = generateJoi(permissionInfo.permissions, { diff --git a/app/src/utils/user-name.ts b/app/src/utils/user-name.ts index e6b4b104f1..1517caaa85 100644 --- a/app/src/utils/user-name.ts +++ b/app/src/utils/user-name.ts @@ -2,6 +2,10 @@ import { i18n } from '@/lang'; import { User } from '@directus/shared/types'; export function userName(user: Partial): string { + if (!user) { + return i18n.global.t('unknown_user') as string; + } + if (user.first_name && user.last_name) { return `${user.first_name} ${user.last_name}`; } diff --git a/app/src/views/private/components/drawer-item/drawer-item.vue b/app/src/views/private/components/drawer-item/drawer-item.vue index fbffae80f1..90428aaae6 100644 --- a/app/src/views/private/components/drawer-item/drawer-item.vue +++ b/app/src/views/private/components/drawer-item/drawer-item.vue @@ -32,6 +32,7 @@ /> >, @@ -92,6 +94,10 @@ export default defineComponent({ type: String, default: null, }, + disabled: { + type: Boolean, + default: false, + }, // There's an interesting case where the main form can be a newly created item ('+'), while // it has a pre-selected related item it needs to alter. In that case, we have to fetch the // related data anyway. diff --git a/app/src/views/private/components/module-bar-avatar/module-bar-avatar.vue b/app/src/views/private/components/module-bar-avatar/module-bar-avatar.vue index e574cb7ae7..9859adac40 100644 --- a/app/src/views/private/components/module-bar-avatar/module-bar-avatar.vue +++ b/app/src/views/private/components/module-bar-avatar/module-bar-avatar.vue @@ -73,9 +73,7 @@ export default defineComponent({ const signOutActive = ref(false); const avatarURL = computed(() => { - if (userStore.currentUser === null) return null; - if (userStore.currentUser.avatar === null) return null; - + if (!userStore.currentUser || !('avatar' in userStore.currentUser) || !userStore.currentUser?.avatar) return null; return addTokenToURL(getRootPath() + `assets/${userStore.currentUser.avatar.id}?key=system-medium-cover`); }); diff --git a/app/src/views/private/components/shares-sidebar-detail/index.ts b/app/src/views/private/components/shares-sidebar-detail/index.ts new file mode 100644 index 0000000000..70349f65bb --- /dev/null +++ b/app/src/views/private/components/shares-sidebar-detail/index.ts @@ -0,0 +1,4 @@ +import SharesSidebarDetail from './shares-sidebar-detail.vue'; + +export { SharesSidebarDetail }; +export default SharesSidebarDetail; diff --git a/app/src/views/private/components/shares-sidebar-detail/share-item.vue b/app/src/views/private/components/shares-sidebar-detail/share-item.vue new file mode 100644 index 0000000000..d53af08ab9 --- /dev/null +++ b/app/src/views/private/components/shares-sidebar-detail/share-item.vue @@ -0,0 +1,203 @@ + + + + + diff --git a/app/src/views/private/components/shares-sidebar-detail/shares-sidebar-detail.vue b/app/src/views/private/components/shares-sidebar-detail/shares-sidebar-detail.vue new file mode 100644 index 0000000000..207d020567 --- /dev/null +++ b/app/src/views/private/components/shares-sidebar-detail/shares-sidebar-detail.vue @@ -0,0 +1,295 @@ + + + + + diff --git a/app/src/views/private/private-view.vue b/app/src/views/private/private-view.vue index 9f12042904..16e6530c77 100644 --- a/app/src/views/private/private-view.vue +++ b/app/src/views/private/private-view.vue @@ -3,7 +3,7 @@ {{ t('no_app_access_copy') }} diff --git a/app/src/views/public/readme.md b/app/src/views/public/readme.md deleted file mode 100644 index 1f5c78bdf6..0000000000 --- a/app/src/views/public/readme.md +++ /dev/null @@ -1,22 +0,0 @@ -# Public View - -## Props - -| Prop | Description | Default | -| ------ | ------------------------------------------------- | ------- | -| `wide` | Renders the container area with a wider max width | `false` | - -## Events - -n/a - -## Slots - -| Slot | Description | Data | -| --------- | -------------------------------------------------- | ---- | -| _default_ | | -- | -| `notice` | Notice after all the content. Sticks to the bottom | -- | - -## CSS Variables - -n/a diff --git a/app/src/views/register.ts b/app/src/views/register.ts index c8180ade2c..88b6b0f409 100644 --- a/app/src/views/register.ts +++ b/app/src/views/register.ts @@ -1,9 +1,11 @@ import { App, defineAsyncComponent } from 'vue'; import PublicView from './public/'; +import SharedView from './shared/shared-view.vue'; const PrivateView = defineAsyncComponent(() => import('./private')); export function registerViews(app: App): void { app.component('PublicView', PublicView); app.component('PrivateView', PrivateView); + app.component('SharedView', SharedView); } diff --git a/app/src/views/shared/shared-view.vue b/app/src/views/shared/shared-view.vue new file mode 100644 index 0000000000..63dce2286f --- /dev/null +++ b/app/src/views/shared/shared-view.vue @@ -0,0 +1,179 @@ + + + + + diff --git a/packages/shared/src/types/accountability.ts b/packages/shared/src/types/accountability.ts index 915ccb3dc7..6373b28246 100644 --- a/packages/shared/src/types/accountability.ts +++ b/packages/shared/src/types/accountability.ts @@ -1,11 +1,18 @@ import { Permission } from '.'; +export type ShareScope = { + collection: string; + item: string; +}; + export type Accountability = { role: string | null; user?: string | null; admin?: boolean; app?: boolean; permissions?: Permission[]; + share?: string; + share_scope?: ShareScope; ip?: string; userAgent?: string; diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index a883e1378f..8bfa5682d2 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -19,4 +19,5 @@ export * from './presets'; export * from './query'; export * from './relations'; export * from './settings'; +export * from './shares'; export * from './users'; diff --git a/packages/shared/src/types/permissions.ts b/packages/shared/src/types/permissions.ts index d3db7e91bc..c322cdb346 100644 --- a/packages/shared/src/types/permissions.ts +++ b/packages/shared/src/types/permissions.ts @@ -1,6 +1,6 @@ import { Filter } from './filter'; -export type PermissionsAction = 'create' | 'read' | 'update' | 'delete' | 'comment' | 'explain'; +export type PermissionsAction = 'create' | 'read' | 'update' | 'delete' | 'comment' | 'explain' | 'share'; export type Permission = { id?: number; diff --git a/packages/shared/src/types/shares.ts b/packages/shared/src/types/shares.ts new file mode 100644 index 0000000000..44e5c720d2 --- /dev/null +++ b/packages/shared/src/types/shares.ts @@ -0,0 +1,16 @@ +import { User } from './users'; + +export type Share = { + id: string; + name: string; + collection: string; + item: string; + role: string; + password: string; + user_created: string | User; + date_created: string; + date_start: string | null; + date_end: string | null; + times_used: number; + max_uses: number | null; +};