From 46f60a671aa3986a4f097f8f13c8e516a3b5837d Mon Sep 17 00:00:00 2001 From: garrettmills Date: Fri, 14 Feb 2020 00:11:16 -0600 Subject: [PATCH] Permissions refactor; create sharing API; update dependencies --- app/controllers/api/v1/File.controller.js | 18 +-- app/controllers/api/v1/FormCode.controller.js | 8 +- .../api/v1/FormDatabase.controller.js | 26 ++--- app/controllers/api/v1/Menu.controller.js | 68 +++++++++++- app/controllers/api/v1/Page.controller.js | 20 ++-- app/controllers/api/v1/Sharing.controller.js | 74 ++++++++++++ app/models/api/Page.model.js | 105 +++++++++++++++++- app/models/auth/KeyAction.model.js | 3 + .../middleware/api/PageRoute.middleware.js | 40 +++++++ .../api/RequiredFields.middleware.js | 87 +++++++++++++++ .../middleware/api/UserRoute.middleware.js | 35 ++++++ app/routing/routers/api/v1.routes.js | 67 ++++++----- app/routing/routers/api/v1/sharing.routes.js | 46 ++++++++ app/routing/routers/auth/keyaction.routes.js | 10 ++ config/api/forms/sharing.config.js | 55 +++++++++ package.json | 4 +- yarn.lock | 18 +-- 17 files changed, 609 insertions(+), 75 deletions(-) create mode 100644 app/controllers/api/v1/Sharing.controller.js create mode 100644 app/routing/middleware/api/PageRoute.middleware.js create mode 100644 app/routing/middleware/api/RequiredFields.middleware.js create mode 100644 app/routing/middleware/api/UserRoute.middleware.js create mode 100644 app/routing/routers/api/v1/sharing.routes.js create mode 100644 config/api/forms/sharing.config.js diff --git a/app/controllers/api/v1/File.controller.js b/app/controllers/api/v1/File.controller.js index 8f5d3f2..9738b74 100644 --- a/app/controllers/api/v1/File.controller.js +++ b/app/controllers/api/v1/File.controller.js @@ -19,7 +19,7 @@ class File extends Controller { let page = await Page.findOne({UUID: PageId}) if ( !page ) return res.status(404).message('Page not found with that ID.').api({}) - if ( !page.accessible_by(req.user) ) return req.security.deny() + if ( !(await page.is_accessible_by(req.user, 'update')) ) return req.security.deny() const NodeId = req.params.NodeId @@ -44,7 +44,7 @@ class File extends Controller { let page = await Page.findOne({UUID: PageId}) if ( !page ) return res.status(404).message('Page not found with that ID.').api({}) - if ( !page.accessible_by(req.user) ) return req.security.deny() + if ( !(await page.is_accessible_by(req.user)) ) return req.security.deny() const NodeId = req.params.NodeId @@ -53,7 +53,7 @@ class File extends Controller { const group = await FileGroup.findOne({UUID: req.params.FilesId}) if ( !group ) return res.status(404).message('Invalid file group.').api({}) - if ( !group.accessible_by(req.user) ) return req.security.deny() + // if ( !group.accessible_by(req.user) ) return req.security.deny() const File = this.models.get('upload::File') const files = await File.find({_id: {$in: group.FileIds.map(x => ObjectId(x))}}) @@ -67,7 +67,7 @@ class File extends Controller { let page = await Page.findOne({UUID: PageId}) if ( !page ) return res.status(404).message('Page not found with that ID.').api({}) - if ( !page.accessible_by(req.user) ) return req.security.deny() + if ( !(await page.is_accessible_by(req.user, 'update')) ) return req.security.deny() const NodeId = req.params.NodeId @@ -76,7 +76,7 @@ class File extends Controller { const group = await FileGroup.findOne({UUID: req.params.FilesId}) if ( !group ) return res.status(404).message('Invalid file group.').api({}) - if ( !group.accessible_by(req.user) ) return req.security.deny() + // if ( !group.accessible_by(req.user) ) return req.security.deny() if ( req.uploads.uploaded_file ) { group.FileIds.push(req.uploads.uploaded_file.id) @@ -91,7 +91,7 @@ class File extends Controller { let page = await Page.findOne({UUID: PageId}) if ( !page ) return res.status(404).message('Page not found with that ID.').api({}) - if ( !page.accessible_by(req.user) ) return req.security.deny() + if ( !(await page.is_accessible_by(req.user)) ) return req.security.deny() const NodeId = req.params.NodeId @@ -100,7 +100,7 @@ class File extends Controller { const group = await FileGroup.findOne({UUID: req.params.FilesId}) if ( !group ) return res.status(404).message('Invalid file group.').api({}) - if ( !group.accessible_by(req.user) ) return req.security.deny() + // if ( !group.accessible_by(req.user) ) return req.security.deny() if ( !group.FileIds.includes(req.params.FileId) ) { return req.security.deny() @@ -118,7 +118,7 @@ class File extends Controller { let page = await Page.findOne({UUID: PageId}) if ( !page ) return res.status(404).message('Page not found with that ID.').api({}) - if ( !page.accessible_by(req.user) ) return req.security.deny() + if ( !(await page.is_accessible_by(req.user, 'update')) ) return req.security.deny() const NodeId = req.params.NodeId @@ -127,7 +127,7 @@ class File extends Controller { const group = await FileGroup.findOne({UUID: req.params.FilesId}) if ( !group ) return res.status(404).message('Invalid file group.').api({}) - if ( !group.accessible_by(req.user) ) return req.security.deny() + // if ( !group.accessible_by(req.user) ) return req.security.deny() await group.delete() return res.api({}) diff --git a/app/controllers/api/v1/FormCode.controller.js b/app/controllers/api/v1/FormCode.controller.js index 6c2f0d0..e3363ac 100644 --- a/app/controllers/api/v1/FormCode.controller.js +++ b/app/controllers/api/v1/FormCode.controller.js @@ -15,7 +15,7 @@ class FormCode extends Controller { let page = await Page.findOne({UUID: PageId}) if ( !page ) return res.status(404).message('Page not found with that ID.').api({}) - if ( !page.accessible_by(req.user) ) return req.security.deny() + if ( !(await page.is_accessible_by(req.user, 'update')) ) return req.security.deny() const NodeId = req.params.NodeId @@ -37,7 +37,7 @@ class FormCode extends Controller { let page = await Page.findOne({UUID: PageId}) if ( !page ) return res.status(404).message('Page not found with that ID.').api({}) - if ( !page.accessible_by(req.user) ) return req.security.deny() + if ( !(await page.is_accessible_by(req.user)) ) return req.security.deny() const NodeId = req.params.NodeId @@ -55,7 +55,7 @@ class FormCode extends Controller { let page = await Page.findOne({UUID: PageId}) if ( !page ) return res.status(404).message('Page not found with that ID.').api({}) - if ( !page.accessible_by(req.user) ) return req.security.deny() + if ( !(await page.is_accessible_by(req.user, 'update')) ) return req.security.deny() const NodeId = req.params.NodeId @@ -78,7 +78,7 @@ class FormCode extends Controller { let page = await Page.findOne({UUID: PageId}) if ( !page ) return res.status(404).message('Page not found with that ID.').api({}) - if ( !page.accessible_by(req.user) ) return req.security.deny() + if ( !(await page.is_accessible_by(req.user, 'update')) ) return req.security.deny() const NodeId = req.params.NodeId diff --git a/app/controllers/api/v1/FormDatabase.controller.js b/app/controllers/api/v1/FormDatabase.controller.js index e2faae2..f244b6a 100644 --- a/app/controllers/api/v1/FormDatabase.controller.js +++ b/app/controllers/api/v1/FormDatabase.controller.js @@ -17,7 +17,7 @@ class FormDatabase extends Controller { let page = await Page.findOne({UUID: PageId}) if ( !page ) return res.status(404).message('Page not found with that ID.').api({}) - if ( !page.accessible_by(req.user) ) return req.security.deny() + if ( !(await page.is_accessible_by(req.user, 'update')) ) return req.security.deny() const NodeId = req.params.NodeId @@ -43,7 +43,7 @@ class FormDatabase extends Controller { let page = await Page.findOne({UUID: PageId}) if ( !page ) return res.status(404).message('Page not found with that ID.').api({}) - if ( !page.accessible_by(req.user) ) return req.security.deny() + if ( !(await page.is_accessible_by(req.user)) ) return req.security.deny() const NodeId = req.params.NodeId @@ -53,7 +53,7 @@ class FormDatabase extends Controller { const DatabaseId = req.params.DatabaseId const db = await Database.findOne({UUID: DatabaseId}) if ( !db ) return res.status(404).message('Database not found with that ID.').api({}) - if ( !db.accessible_by(req.user) ) return req.security.deny() + // if ( !db.accessible_by(req.user) ) return req.security.deny() return res.api(db) } @@ -63,7 +63,7 @@ class FormDatabase extends Controller { let page = await Page.findOne({UUID: PageId}) if ( !page ) return res.status(404).message('Page not found with that ID.').api({}) - if ( !page.accessible_by(req.user) ) return req.security.deny() + if ( !(await page.is_accessible_by(req.user)) ) return req.security.deny() const NodeId = req.params.NodeId @@ -73,7 +73,7 @@ class FormDatabase extends Controller { const DatabaseId = req.params.DatabaseId const db = await Database.findOne({UUID: DatabaseId}) if ( !db ) return res.status(404).message('Database not found with that ID.').api({}) - if ( !db.accessible_by(req.user) ) return req.security.deny() + // if ( !db.accessible_by(req.user) ) return req.security.deny() const columns = await ColumnDef.find({ DatabaseId: db.UUID }) return res.api(columns) @@ -84,7 +84,7 @@ class FormDatabase extends Controller { let page = await Page.findOne({UUID: PageId}) if ( !page ) return res.status(404).message('Page not found with that ID.').api({}) - if ( !page.accessible_by(req.user) ) return req.security.deny() + if ( !(await page.is_accessible_by(req.user, 'update')) ) return req.security.deny() const NodeId = req.params.NodeId @@ -94,7 +94,7 @@ class FormDatabase extends Controller { const DatabaseId = req.params.DatabaseId const db = await Database.findOne({UUID: DatabaseId}) if ( !db ) return res.status(404).message('Database not found with that ID.').api({}) - if ( !db.accessible_by(req.user, 'update') ) return req.security.deny() + // if ( !db.accessible_by(req.user, 'update') ) return req.security.deny() const existing_columns = await ColumnDef.find({ DatabaseId: db.UUID }) const assoc_columns = [] @@ -136,7 +136,7 @@ class FormDatabase extends Controller { let page = await Page.findOne({UUID: PageId}) if ( !page ) return res.status(404).message('Page not found with that ID.').api({}) - if ( !page.accessible_by(req.user) ) return req.security.deny() + if ( !(await page.is_accessible_by(req.user)) ) return req.security.deny() const NodeId = req.params.NodeId @@ -146,7 +146,7 @@ class FormDatabase extends Controller { const DatabaseId = req.params.DatabaseId const db = await Database.findOne({UUID: DatabaseId}) if ( !db ) return res.status(404).message('Database not found with that ID.').api({}) - if ( !db.accessible_by(req.user) ) return req.security.deny() + // if ( !db.accessible_by(req.user) ) return req.security.deny() const entries = await DBEntry.find({DatabaseId: db.UUID}) entries.forEach(entry => entry.RowData.UUID = entry.UUID) @@ -159,7 +159,7 @@ class FormDatabase extends Controller { let page = await Page.findOne({UUID: PageId}) if ( !page ) return res.status(404).message('Page not found with that ID.').api({}) - if ( !page.accessible_by(req.user) ) return req.security.deny() + if ( !(await page.is_accessible_by(req.user, 'update')) ) return req.security.deny() const NodeId = req.params.NodeId @@ -169,7 +169,7 @@ class FormDatabase extends Controller { const DatabaseId = req.params.DatabaseId const db = await Database.findOne({UUID: DatabaseId}) if ( !db ) return res.status(404).message('Database not found with that ID.').api({}) - if ( !db.accessible_by(req.user) ) return req.security.deny() + // if ( !db.accessible_by(req.user) ) return req.security.deny() await DBEntry.deleteMany({DatabaseId: db.UUID}) @@ -193,7 +193,7 @@ class FormDatabase extends Controller { let page = await Page.findOne({UUID: PageId}) if ( !page ) return res.status(404).message('Page not found with that ID.').api({}) - if ( !page.accessible_by(req.user) ) return req.security.deny() + if ( !(await page.is_accessible_by(req.user, 'update')) ) return req.security.deny() const NodeId = req.params.NodeId @@ -203,7 +203,7 @@ class FormDatabase extends Controller { const DatabaseId = req.params.DatabaseId const db = await Database.findOne({UUID: DatabaseId}) if ( !db ) return res.status(404).message('Database not found with that ID.').api({}) - if ( !db.accessible_by(req.user) ) return req.security.deny() + // if ( !db.accessible_by(req.user) ) return req.security.deny() await DBEntry.deleteMany({DatabaseId: db.UUID}) await db.delete() diff --git a/app/controllers/api/v1/Menu.controller.js b/app/controllers/api/v1/Menu.controller.js index f651ff8..f34ef03 100644 --- a/app/controllers/api/v1/Menu.controller.js +++ b/app/controllers/api/v1/Menu.controller.js @@ -11,6 +11,8 @@ class Menu extends Controller { } async get_items(req, res) { + const Page = this.models.get('api:Page') + // Build the "My Tree" option const root_page = await req.user.get_root_page() const nodes = await this._build_menu_object(root_page) @@ -24,6 +26,53 @@ class Menu extends Controller { virtual: true, }) + // Get view only shared trees + const view_only_trees = await Page.find({ shared_users_view: req.user._id }) + const view_only_nodes = [] + for ( const tree of view_only_trees ) { + if ( !(await tree.is_accessible_by(req.user)) ) continue + view_only_nodes.push({ + id: tree.UUID, + name: tree.Name, + children: await this._build_secure_menu_object(tree, req.user), + level: await tree.access_level_for(req.user), + }) + } + + // Get update, view shared trees + const update_trees = await Page.find({ shared_users_update: req.user._id }) + const update_nodes = [] + for ( const tree of update_trees ) { + if ( !(await tree.is_accessible_by(req.user)) ) continue + update_nodes.push({ + id: tree.UUID, + name: tree.Name, + children: await this._build_secure_menu_object(tree, req.user), + level: await tree.access_level_for(req.user), + }) + } + + // Get update, view, manage shared trees + const manage_trees = await Page.find({ shared_users_manage: req.user._id }) + const manage_nodes = [] + for ( const tree of manage_trees ) { + if ( !(await tree.is_accessible_by(req.user)) ) continue + manage_nodes.push({ + id: tree.UUID, + name: tree.Name, + children: await this._build_secure_menu_object(tree, req.user), + level: await tree.access_level_for(req.user), + }) + } + + menu.push({ + id: 0, + name: 'Trees Shared With Me', + children: [...view_only_nodes, ...update_nodes, ...manage_nodes], + noDelete: true, + virtual: true, + }) + return res.api(menu) } @@ -33,7 +82,8 @@ class Menu extends Controller { for ( const child of children ) { arr.push({ id: child.UUID, - name: child.Name, + name: child.is_shared() ? child.Name + ' ⁽ˢʰᵃʳᵉᵈ⁾' : child.Name, + shared: child.is_shared(), children: await this._build_menu_object(child), }) } @@ -42,6 +92,22 @@ class Menu extends Controller { return arr } + async _build_secure_menu_object(parent_node, user, arr = []) { + const children = await parent_node.childPages + if ( children ) { + for ( const child of children ) { + if ( !(await child.is_accessible_by(user)) ) continue + arr.push({ + id: child.UUID, + name: child.Name, + children: await this._build_secure_menu_object(child, user), + level: await child.access_level_for(user), + }) + } + } + return arr + } + } module.exports = exports = Menu diff --git a/app/controllers/api/v1/Page.controller.js b/app/controllers/api/v1/Page.controller.js index 2b55b27..986afa4 100644 --- a/app/controllers/api/v1/Page.controller.js +++ b/app/controllers/api/v1/Page.controller.js @@ -14,7 +14,9 @@ class Page extends Controller { const page = await PageModel.findOne({UUID: PageId}) if ( !page ) return res.status(404).message('Page not found with that ID.').api({}) - if ( !page.accessible_by(user) ) return req.security.deny() + + if ( !(await page.is_accessible_by(user)) ) return req.security.deny() + page.level = await page.access_level_for(req.user) return res.api(page) } @@ -26,7 +28,7 @@ class Page extends Controller { if ( PageId ) { page = await PageModel.findOne({UUID: PageId}) if ( !page ) return res.status(404).message('Page not found with that ID.').api({}) - if ( !page.accessible_by(req.user, 'update') ) return req.security.deny() + if ( !(await page.is_accessible_by(req.user, 'update')) ) return req.security.deny() } else { page = new PageModel page.CreatedUserId = req.user.id @@ -44,13 +46,13 @@ class Page extends Controller { page.IsVisibleInMenu = !!req.body.IsVisibleInMenu } - let parent; + /*let parent; if ( !req.body.ParentId ) return res.status(400).message('Missing required: ParentId').api({}) else { parent = await PageModel.findOne({UUID: req.body.ParentId}) if ( !parent ) return res.status(404).message('Parent page not found with that ID.').api({}) - if ( !parent.accessible_by(req.user, 'update') ) return req.security.kickout() - } + if ( !(await parent.is_accessible_by(req.user, 'update')) ) return req.security.kickout() + }*/ // TODO re-implement this to account for sharing page.UpdatedAt = new Date page.UpdateUserId = req.user._id @@ -66,7 +68,7 @@ class Page extends Controller { if ( PageId ) { page = await PageModel.findOne({UUID: PageId}) if ( !page ) return res.status(404).message('Page not found with that ID.').api({}) - if ( !page.accessible_by(req.user) ) return req.security.deny() + if ( !(await page.is_accessible_by(req.user)) ) return req.security.deny() } const nodes = await Node.find({PageId: page.UUID}); @@ -89,7 +91,7 @@ class Page extends Controller { if ( PageId ) { page = await PageModel.findOne({UUID: PageId}) if ( !page ) return res.status(404).message('Page not found with that ID.').api({}) - if ( !page.accessible_by(req.user, 'update') ) return req.security.deny() + if ( !(await page.is_accessible_by(req.user, 'update')) ) return req.security.deny() } const nodes = await Node.find({PageId: page.UUID}) @@ -204,7 +206,7 @@ class Page extends Controller { const parent = await PageModel.findOne({UUID: req.body.parentId}) if ( !parent ) { return res.status(404).message('Unable to find parent with that ID.').api({}) - } else if ( !parent.accessible_by(req.user, 'update') ) { + } else if ( !(await parent.is_accessible_by(req.user, 'manage')) ) { return res.security.deny() } @@ -248,7 +250,7 @@ class Page extends Controller { if ( PageId ) { page = await PageModel.findOne({UUID: PageId}) if ( !page ) return res.status(404).message('Page not found with that ID.').api({}) - if ( !page.accessible_by(req.user, 'update') ) return req.security.deny() + if ( !(await page.is_accessible_by(req.user, 'manage')) ) return req.security.deny() if ( page.ParentId === '0' ) return req.security.kickout() } diff --git a/app/controllers/api/v1/Sharing.controller.js b/app/controllers/api/v1/Sharing.controller.js new file mode 100644 index 0000000..7d53737 --- /dev/null +++ b/app/controllers/api/v1/Sharing.controller.js @@ -0,0 +1,74 @@ +const Controller = require('libflitter/controller/Controller') + +/* + * Sharing Controller + * ------------------------------------------------------------- + * Put some description here! + */ +class Sharing extends Controller { + static get services() { + return [...super.services, 'models'] + } + + async share_page(req, res) { + const level = req.form.level + await req.form.page.share_with(req.form.user, level) + return res.api({}) + } + + async revoke_page(req, res) { + await req.form.page.unshare_with(req.form.user) + return res.api({}) + } + + async page_info(req, res) { + const data = { + view: (await req.form.page.view_users).map(x => { + return {username: x.uid, id: x.id, level: 'view'} + }), + update: (await req.form.page.update_users).map(x => { + return {username: x.uid, id: x.id, level: 'update'} + }), + manage: (await req.form.page.manage_users).map(x => { + return {username: x.uid, id: x.id, level: 'manage'} + }), + } + + return res.api(data) + } + + async get_link(req, res) { + const KeyAction = this.models.get('auth:KeyAction') + const in_1_week = new Date + in_1_week.setDate(in_1_week.getDate() + 7) + + const action = new KeyAction({ + handler: 'controller::api:v1:Sharing.accept_link', + expires: in_1_week, + auto_login: false, + no_auto_logout: true, // THIS IS FINE. It's because the MW requires a traditional sign-in. + }) + + await action.save() + action.data_set('level', req.form.level) + action.data_set('PageId', req.form.page.UUID) + await action.save() + + return res.api({ link: action.auth_url() }) + } + + async accept_link(req, res) { + if ( !req.user ) return req.security.kickout() + const Page = this.models.get('api:Page') + const PageId = req.key_action.data_get('PageId') + const level = req.key_action.data_get('level') + + const page = await Page.findOne({UUID: PageId}) + await page.share_with(req.user, level) + + return res.redirect(`/i/editor;id=${PageId}`) + } + +} + +module.exports = exports = Sharing diff --git a/app/models/api/Page.model.js b/app/models/api/Page.model.js index f663980..15ffe89 100644 --- a/app/models/api/Page.model.js +++ b/app/models/api/Page.model.js @@ -31,14 +31,24 @@ class Page extends Model { UpdateUserId: {type: String}, ChildPageIds: [String], + // Menu flags noDelete: { type: Boolean, default: false }, virtual: { type: Boolean, default: false }, + + // Sharing properties + shared_users_view: [ObjectId], + shared_users_update: [ObjectId], + shared_users_manage: [ObjectId], }; } static scopes = [new ActiveScope] - // Static and instance methods can go here + is_shared() { + return this.shared_users_view.length > 0 || this.shared_users_update.length > 0 || this.shared_users_manage.length > 0 + } + + // ================= RELATIONSHIPS ================= get user() { const User = this.models.get("auth:User") return this.belongs_to_one(User, "OrgUserId", "_id") @@ -58,8 +68,99 @@ class Page extends Model { return this.belongs_to_one(Parent, "ParentId", "UUID") } + get view_users() { + const User = this.models.get('auth:User') + return this.has_many(User, 'shared_users_view', '_id') + } + + get update_users() { + const User = this.models.get('auth:User') + return this.has_many(User, 'shared_users_update', '_id') + } + + get manage_users() { + const User = this.models.get('auth:User') + return this.has_many(User, 'shared_users_manage', '_id') + } + + // ================= SECURITY ================= accessible_by(user, mode = 'view') { - return user.can(`page:${this.UUID}:${mode}`) + const base_access = user.can(`page:${this.UUID}:${mode}`) + } + + async is_accessible_by(user, mode = 'view') { + const can_manage = user.can(`page:${this.UUID}:manage`) + const can_update = user.can(`page:${this.UUID}:update`) + const can_view = user.can(`page:${this.UUID}:view`) + const can_all = user.can(`page:${this.UUID}`) + + // Allow universal access + if ( can_all ) return true + // deny if blocked + else if ( user.can(`page:${this.UUID}:block`) ) return false + // manage, update, view can view + else if ( mode === 'view' && (can_manage || can_update || can_view) ) return true + // manage, update can update + else if ( mode === 'update' ) { + if ( can_manage || can_update ) return true + // If other permissions are explicitly set for this page, use those + else if ( can_view ) return false + } + // manage can manage + else if ( mode === 'manage' ) { + if ( can_manage ) return true + // If other permissions are explicitly set for this page, use those + else if ( can_update || can_view ) return false + } + + // allow universal access + // deny blocked users + + // If there are no explicit permissions set for this node, check the parent + const parent = await this.parent + if ( parent ) return (await this.parent).is_accessible_by(user, mode) + else return false + } + + async access_level_for(user) { + if ( await this.is_accessible_by(user, 'manage') ) return 'manage' + else if ( await this.is_accessible_by(user, 'update') ) return 'update' + else if ( await this.is_accessible_by(user, 'view') ) return 'view' + else return false + } + + async share_with(user, level = 'view') { + if ( !['view', 'update', 'manage'].includes(level) ) { + throw new Error(`Invalid share level: ${level}`) + } + + // Remove existing sharing info + await this.unshare_with(user) + + // Add the page to the user's permissions: + user.allow(`page:${this.UUID}:${level}`) + + // Add the user to the appropriate access list + this[`shared_users_${level}`].push(user._id) + + await this.save() + await user.save() + } + + async unshare_with(user) { + // Remove this page from the user's permissions + if ( user.can(`page:${this.UUID}`) ) user.disallow(`page:${this.UUID}`) + for ( const level of ['view', 'update', 'manage'] ) { + if ( user.can(`page:${this.UUID}:${level}`) ) user.disallow(`page:${this.UUID}:${level}`) + } + + // Remove the user from this page's access lists + this.shared_users_view = this.shared_users_view.filter(x => String(x) !== user.id) + this.shared_users_update = this.shared_users_update.filter(x => String(x) !== user.id) + this.shared_users_manage = this.shared_users_manage.filter(x => String(x) !== user.id) + + await this.save() + await user.save() } } diff --git a/app/models/auth/KeyAction.model.js b/app/models/auth/KeyAction.model.js index 2baf07c..22c1c6f 100644 --- a/app/models/auth/KeyAction.model.js +++ b/app/models/auth/KeyAction.model.js @@ -20,6 +20,9 @@ const Model = require('flitter-auth/model/KeyAction') */ class KeyAction extends Model { + auth_url() { + return this.url().replace('auth/action/', 'auth/action/login/') + } } module.exports = exports = KeyAction diff --git a/app/routing/middleware/api/PageRoute.middleware.js b/app/routing/middleware/api/PageRoute.middleware.js new file mode 100644 index 0000000..e0b37dc --- /dev/null +++ b/app/routing/middleware/api/PageRoute.middleware.js @@ -0,0 +1,40 @@ +const Middleware = require('libflitter/middleware/Middleware') + +/* + * PageRoute Middleware + * ------------------------------------------------------------- + * Put some description here! + */ +class PageRoute extends Middleware { + static get services() { + return [...super.services, 'models'] + } + + /* + * Run the middleware test. + * This method is required by all Flitter middleware. + * It should either call the next function in the stack, + * or it should handle the response accordingly. + */ + async test(req, res, next, args = {}){ + const Page = this.models.get('api:Page') + const PageId = req.form.PageId ? req.form.PageId : req.params.PageId + if ( !PageId ) return res.status(400).message(`Missing PageId.`).api({}) + + const level = args.level ? args.level : 'view' + const page = await Page.findOne({UUID: PageId}) + + if ( !page ) return res.status(404).message(`Unable to find page with that id.`).api({}) + if ( !(await page.is_accessible_by(req.user, level)) ) return req.security.deny() + + if ( !req.form ) req.form = {} + req.form.page = page + + /* + * Call the next function in the stack. + */ + next() + } +} + +module.exports = exports = PageRoute diff --git a/app/routing/middleware/api/RequiredFields.middleware.js b/app/routing/middleware/api/RequiredFields.middleware.js new file mode 100644 index 0000000..61bb838 --- /dev/null +++ b/app/routing/middleware/api/RequiredFields.middleware.js @@ -0,0 +1,87 @@ +const Middleware = require('libflitter/middleware/Middleware') + +/* + * RequiredFields Middleware + * ------------------------------------------------------------- + * Put some description here! + */ +class RequiredFields extends Middleware { + static get services() { + return [...super.services, 'configs', 'output', 'utility'] + } + + /* + * Run the middleware test. + * This method is required by all Flitter middleware. + * It should either call the next function in the stack, + * or it should handle the response accordingly. + */ + async test(req, res, next, args){ + // Do stuff here + const search_fields = args.search ? (Array.isArray(args.search) ? args.search : [args.search]) : ['params', 'body', 'query'] + const form_config = this.configs.get('api:forms:'+args.form) + const values = {} + + for ( const field in form_config.fields ) { + if ( !form_config.fields.hasOwnProperty(field) ) continue + const field_config = form_config.fields[field] + let field_value = this.get_field({ request: req, field, search_fields }) + + if ( !field_value ) { + if ( field_config.required ) { + return this.fail({ response: res, reason: `Missing required field: ${field}`}) + } + } else { + if ( field_config.infer !== false ) { + field_value = this.utility.infer(field_value) + } + + if ( field_config.coerce ) { + field_value = field_config.coerce(field_value) + + if ( field_config.coerce === Number && isNaN(field_value) ) { + return this.fail({ response: res, reason: 'Invalid numerical value for field: '+field }) + } + } + + if ( field_config.in_set ) { + if ( Array.isArray(field_config.in_set) ) { + if ( !field_config.in_set.includes(field_value) ) { + return this.fail({ response: res, reason: `Invalid value for ${field}. Value must be one of: ${field_config.join(', ')}`}) + } + } else { + this.output.warn(`[Middleware RequiredFields] Invalid in_set for ${field} in ${args.form}. Must be array.`) + } + } + + values[field] = field_value + } + } + + req.form = values + + /* + * Call the next function in the stack. + */ + next() + } + + get_field({ request, field, search_fields }) { + for ( const search_field of search_fields ) { + if ( Object.keys(request).includes(search_field) ) { + if ( Object.keys(request[search_field]).includes(field) ) { + return request[search_field][field] + } + } else { + this.output.warn(`[Middleware RequiredFields] Requested search of request field that does not exist: ${search_field}`) + this.output.debug(`[Middleware RequiredFields] Available request keys: ${Object.keys(request).join(', ')}`) + } + } + } + + fail({ response, reason }) { + return response.status(400).message(reason).api({}) + } +} + +module.exports = exports = RequiredFields diff --git a/app/routing/middleware/api/UserRoute.middleware.js b/app/routing/middleware/api/UserRoute.middleware.js new file mode 100644 index 0000000..cc38f24 --- /dev/null +++ b/app/routing/middleware/api/UserRoute.middleware.js @@ -0,0 +1,35 @@ +const Middleware = require('libflitter/middleware/Middleware') + +/* + * UserRoute Middleware + * ------------------------------------------------------------- + * Put some description here! + */ +class UserRoute extends Middleware { + static get services() { + return [...super.services, 'models'] + } + + /* + * Run the middleware test. + * This method is required by all Flitter middleware. + * It should either call the next function in the stack, + * or it should handle the response accordingly. + */ + async test(req, res, next, args = {}){ + const User = this.models.get('auth:User') + const user_id = req.form.user_id ? req.form.user_id : req.params.user_id + + if ( !user_id ) return res.status(400).message('Midding user_id.').api({}) + + const user = await User.findById(user_id) + if ( !user ) return res.status(404).message('Unable to find user with that ID.').api({}) + + if ( !req.form ) req.form = {} + req.form.user = user + + next() + } +} + +module.exports = exports = UserRoute diff --git a/app/routing/routers/api/v1.routes.js b/app/routing/routers/api/v1.routes.js index 95ab01c..ca69608 100644 --- a/app/routing/routers/api/v1.routes.js +++ b/app/routing/routers/api/v1.routes.js @@ -5,73 +5,88 @@ */ const index = { - /* - * Define the prefix applied to each of these routes. - * For example, if prefix is '/auth': - * '/' becomes '/auth' - * '/login' becomes '/auth/login' - */ prefix: '/api/v1', - /* - * Define middleware that should be applied to all - * routes defined in this file. Middleware should be - * included using its non-prefixed canonical name. - * - * You can pass arguments along to a middleware by - * specifying it as an array where the first element - * is the canonical name of the middleware and the - * second element is the argument passed to the - * handler's exec() method. - */ middleware: [ 'auth:UserOnly', ], - /* - * Define GET routes. - * These routes are registered as GET methods. - * Handlers for these routes should be specified as - * an array of canonical references to controller methods - * or middleware that are applied in order. - */ get: { + // Get the file ref node config for the specified file ref '/files/:PageId/:NodeId/get/:FilesId': ['controller::api:v1:File.get_config'], + + // Download the specified file ID from the specified file ref node '/files/:PageId/:NodeId/get/:FilesId/:FileId': ['controller::api:v1:File.download'], - '/hello_world': ['controller::api:v1:Misc.hello_world'], + // Get the data for the specified page '/page/:PageId': ['controller::api:v1:Page.get_page'], + + // Get the nodes present on the specified page '/page/:PageId/nodes': ['controller::api:v1:Page.get_nodes'], + // Get the user's menu tree '/menu/items': ['controller::api:v1:Menu.get_items'], + + // Get the database ref node config for the specified database '/db/:PageId/:NodeId/get/:DatabaseId': ['controller::api:v1:FormDatabase.get_config'], + + // Get the column config records for the specified database '/db/:PageId/:NodeId/get/:DatabaseId/columns': [ 'controller::api:v1:FormDatabase.get_columns' ], + + // Get the row records for the specified database '/db/:PageId/:NodeId/get/:DatabaseId/data': [ 'controller::api:v1:FormDatabase.get_data' ], + // Get the code ref node config for the specified code editor '/code/:PageId/:NodeId/get/:CodiumId': ['controller::api:v1:FormCode.get_config'], + // Export the entire personal tree as HTML '/data/export/html': ['controller::Export.html_export'], }, post: { + // Upload the file in the 'uploaded_file' key to the specified file ref node '/file/upload/:PageId/:NodeId/:FilesId': ['middleware::upload:UploadFile', 'controller::api:v1:File.save_upload'], + + // Create a new file ref node '/files/:PageId/:NodeId/create': ['controller::api:v1:File.create_config'], + + // Delete a file ref node and its files '/files/:PageId/:NodeId/delete/:FilesId': ['controller::api:v1:File.delete_group'], - + // Save the data for the specified page '/page/:PageId/save': ['controller::api:v1:Page.save_page'], + + // Save the node data for the specified page '/page/:PageId/nodes/save': ['controller::api:v1:Page.save_nodes'], + + // Create a new page in the personal root '/page/create': ['controller::api:v1:Page.create_top_level'], + + // Create a new page as a child of the specified page '/page/create-child': ['controller::api:v1:Page.create_child'], + + // Delete the specified page '/page/delete/:PageId': ['controller::api:v1:Page.delete_page'], + // Create a new database ref config '/db/:PageId/:NodeId/create': ['controller::api:v1:FormDatabase.create_new'], + + // Set the column configs for a database ref '/db/:PageId/:NodeId/set/:DatabaseId/columns': [ 'controller::api:v1:FormDatabase.set_columns' ], + + // Delete the specified database ref '/db/:PageId/:NodeId/drop/:DatabaseId': [ 'controller::api:v1:FormDatabase.drop_database' ], + + // Set the row data for the specified database ref '/db/:PageId/:NodeId/set/:DatabaseId/data': ['controller::api:v1:FormDatabase.set_data'], + // Create a new code ref config '/code/:PageId/:NodeId/create': ['controller::api:v1:FormCode.create_new'], + + // Set the data for the specified code ref '/code/:PageId/:NodeId/set/:CodiumId': ['controller::api:v1:FormCode.set_values'], + + // delete the specified code ref '/code/:PageId/:NodeId/delete/:CodiumId': ['controller::api:v1:FormCode.drop_code'], }, } diff --git a/app/routing/routers/api/v1/sharing.routes.js b/app/routing/routers/api/v1/sharing.routes.js new file mode 100644 index 0000000..186de5d --- /dev/null +++ b/app/routing/routers/api/v1/sharing.routes.js @@ -0,0 +1,46 @@ +/* + * API v1 Routes + * ------------------------------------------------------------- + * Description here + */ +const index = { + + prefix: '/api/v1/share', + + middleware: [ + 'auth:UserOnly', + ], + + get: { + '/page/:PageId/info': [ + ['middleware::api:RequiredFields', { form: 'sharing.page' }], + ['middleware::api:PageRoute', {level: 'manage'}], + 'controller::api:v1:Sharing.page_info', + ], + '/page/:PageId/link/:level': [ + ['middleware::api:RequiredFields', { form: 'sharing.page_link'}], + ['middleware::api:PageRoute', {level: 'manage'}], + 'controller::api:v1:Sharing.get_link', + ], + }, + + post: { + // Share a page with the specified user. + '/page/:PageId/share': [ + ['middleware::api:RequiredFields', { form: 'sharing.page_level' }], + ['middleware::api:PageRoute', {level: 'manage'}], + 'middleware::api:UserRoute', + 'controller::api:v1:Sharing.share_page', + ], + + // Unshare a page with the specified user. + '/page/:PageId/revoke': [ + ['middleware::api:RequiredFields', { form: 'sharing.page_user' }], + ['middleware::api:PageRoute', {level: 'manage'}], + 'middleware::api:UserRoute', + 'controller::api:v1:Sharing.revoke_page', + ], + }, +} + +module.exports = exports = index diff --git a/app/routing/routers/auth/keyaction.routes.js b/app/routing/routers/auth/keyaction.routes.js index 29f5f62..6ea8f42 100644 --- a/app/routing/routers/auth/keyaction.routes.js +++ b/app/routing/routers/auth/keyaction.routes.js @@ -6,11 +6,21 @@ module.exports = exports = { 'middleware::auth:KeyAction', 'controller::auth:KeyAction.handle', ], + '/login/:key': [ + 'middleware::auth:UserOnly', + 'middleware::auth:KeyAction', + 'controller::auth:KeyAction.handle', + ], }, post: { '/:key': [ 'middleware::auth:KeyAction', 'controller::auth:KeyAction.handle', ], + '/login/:key': [ + 'middleware::auth:UserOnly', + 'middleware::auth:KeyAction', + 'controller::auth:KeyAction.handle', + ], }, } diff --git a/config/api/forms/sharing.config.js b/config/api/forms/sharing.config.js new file mode 100644 index 0000000..8d7f5f5 --- /dev/null +++ b/config/api/forms/sharing.config.js @@ -0,0 +1,55 @@ +module.exports = exports = { + page: { + fields: { + PageId: { + required: true, + coerce: String, + }, + }, + }, + + page_level: { + fields: { + PageId: { + required: true, + coerce: String, + }, + user_id: { + required: true, + coerce: String, + }, + level: { + required: true, + coerce: String, + in_set: ['view', 'update', 'manage'], + }, + }, + }, + + page_link: { + fields: { + PageId: { + required: true, + coerce: String, + }, + level: { + required: true, + coerce: String, + in_set: ['view', 'update', 'manage'], + }, + }, + }, + + page_user: { + fields: { + PageId: { + required: true, + coerce: String, + }, + user_id: { + required: true, + coerce: String, + }, + }, + }, +} diff --git a/package.json b/package.json index 3e63c9d..93a8244 100644 --- a/package.json +++ b/package.json @@ -22,8 +22,8 @@ "flitter-di": "^0.4.1", "flitter-flap": "^0.5.2", "flitter-forms": "^0.8.1", - "flitter-orm": "^0.2.2", + "flitter-orm": "^0.2.4", "flitter-upload": "^0.8.0", - "libflitter": "^0.46.4" + "libflitter": "^0.46.7" } } diff --git a/yarn.lock b/yarn.lock index 1ed9921..5a71c6d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1041,10 +1041,10 @@ flitter-forms@^0.8.1: recursive-readdir "^2.2.2" validator "^10.11.0" -flitter-orm@^0.2.2: - version "0.2.2" - resolved "https://registry.yarnpkg.com/flitter-orm/-/flitter-orm-0.2.2.tgz#b30e11c32f8467bb540a718a30824c28eb9599bd" - integrity sha512-zZOgbdehzYI21B6/Y0QdVlGIbFgN490TPAwB+krlCbG9Ht7ofoaokRW0WpehDiwfrNkjH+3VgexAW6gQcVhBXw== +flitter-orm@^0.2.4: + version "0.2.4" + resolved "https://registry.yarnpkg.com/flitter-orm/-/flitter-orm-0.2.4.tgz#539f7631fd286955b01ce6034a0bb68142540f5d" + integrity sha512-7yhwwzzBpPIyW4VC9nHY+Pe9pM+EFYwljYKkK1BEMy8XNk6JADhcLiwZGJmxK38vQ8D7SEdFpZiux3fB68uVnQ== dependencies: flitter-di "^0.4.0" json-stringify-safe "^5.0.1" @@ -1509,10 +1509,10 @@ leven@^1.0.2: resolved "https://registry.yarnpkg.com/leven/-/leven-1.0.2.tgz#9144b6eebca5f1d0680169f1a6770dcea60b75c3" integrity sha1-kUS27ryl8dBoAWnxpncNzqYLdcM= -libflitter@^0.46.4: - version "0.46.4" - resolved "https://registry.yarnpkg.com/libflitter/-/libflitter-0.46.4.tgz#ea9d59446c43eb17a4dbf14d8a13f34a1a61c8fd" - integrity sha512-rXlgH44YAkPwKWo0Qu5fKt8zrGKT4DTSQfcFhxsmJVmT+aYoQLDMXQhCk/TgpvsAL+RkGqpCP3/wIum99frraA== +libflitter@^0.46.7: + version "0.46.7" + resolved "https://registry.yarnpkg.com/libflitter/-/libflitter-0.46.7.tgz#be55386a53747e1e21b2aefd356977900fd40dd1" + integrity sha512-PPPEp4vR36xAvjPyxmU8K6NBv6n5ggP/bfSJU/PZ6qCChbYh0gKIN6kH88GM38imSZJxjZM1hjvzio5veIGaAw== dependencies: colors "^1.3.3" connect-mongodb-session "^2.2.0" @@ -1524,7 +1524,7 @@ libflitter@^0.46.4: express-graphql "^0.9.0" express-session "^1.15.6" flitter-di "^0.4.0" - flitter-orm "^0.2.2" + flitter-orm "^0.2.4" graphql "^14.5.4" http-status "^1.4.2" mongo-schematic-class "^1.0.3"