diff --git a/forge/db/models/Team.js b/forge/db/models/Team.js index 9b5014e09e..f140650553 100644 --- a/forge/db/models/Team.js +++ b/forge/db/models/Team.js @@ -367,6 +367,44 @@ module.exports = { count, teams: rows } + }, + /** + * The ids of every team matching a search and filter, with no + * pagination. For callers that need the whole matching set + * rather than a page of it, such as selecting every team an + * announcement should go to. + * + * Shares its where-clause construction with getAll so the ids + * cannot drift from the list the admin is looking at. + * + * @param {Object} pagination search options; `query` only, any cursor is ignored + * @param {Object} where additional filters, as built for getAll + * @param {number} idLimit hard cap on how many ids are returned + * @returns {{ids: string[], count: number, truncated: boolean}} + */ + getAllIds: async (pagination = {}, where = {}, idLimit = 10000) => { + where = buildPaginationSearchClause({ query: pagination.query }, where, ['Team.name']) + if (pagination.query) { + const queryId = M.Team.decodeHashid(pagination.query) + if (queryId && queryId.length === 1) { + // The query term is a valid hashid - go look it up + where = { id: queryId } + } + } + const include = [] + if (app.billing) { + // The billing filter references $Subscription.status$ + include.push({ model: app.db.models.Subscription, attributes: [] }) + } + const [rows, count] = await Promise.all([ + this.findAll({ where, include, attributes: ['id'], order: [['id', 'ASC']], limit: idLimit + 1 }), + this.count({ where, include }) + ]) + return { + ids: rows.slice(0, idLimit).map(row => row.hashid), + count, + truncated: rows.length > idLimit + } } }, instance: { diff --git a/forge/db/models/User.js b/forge/db/models/User.js index 86f29680e5..905a3b8329 100644 --- a/forge/db/models/User.js +++ b/forge/db/models/User.js @@ -314,6 +314,7 @@ module.exports = { * @param {Boolean} options.count only return a count of results * @param {Boolean} options.summary whether to return a limited user object that only contains id: default false * @param {Array} options.teamTypes limit to teams of certain types + * @param {Array} options.teams limit to an explicit list of team ids * @param {Array} options.billing array of billing states to include * @returns Array of users who have at least one of the specific roles, or a count */ @@ -349,15 +350,19 @@ module.exports = { } } } + if (options.teams) { + // Target an explicit set of teams, by raw id + query.include.include.where.id = { [Op.in]: options.teams } + } if (options.teamTypes) { query.include.include.where.TeamTypeId = { [Op.in]: options.teamTypes } - if (options.billing) { - query.include.include.include = { - model: app.db.models.Subscription, - attributes: ['status'], - where: { - status: { [Op.in]: options.billing.map(opt => opt.toLowerCase()) } - } + } + if (options.billing) { + query.include.include.include = { + model: app.db.models.Subscription, + attributes: ['status'], + where: { + status: { [Op.in]: options.billing.map(opt => opt.toLowerCase()) } } } } diff --git a/forge/lib/announcements.js b/forge/lib/announcements.js new file mode 100644 index 0000000000..fd50d0c02a --- /dev/null +++ b/forge/lib/announcements.js @@ -0,0 +1,124 @@ +/** + * Helpers for platform announcements. + * + * Announcement bodies are authored by platform admins and rendered in the + * notifications drawer. Anything that ends up inside an iframe src is + * normalised here, server side, so the front-end never builds an embed URL + * out of free text. + */ + +const YOUTUBE_ID = /^[A-Za-z0-9_-]{11}$/ + +/** + * Turn a user-supplied video link into a structured, safe reference. + * + * Only YouTube is supported. The video id is extracted and validated, so the + * front-end can build the embed URL from a known-good id rather than from the + * string an admin typed. + * + * @param {string} value a YouTube watch, share or embed URL, or a bare video id + * @returns {{provider: string, id: string}|null} null when nothing usable was found + */ +function parseVideoReference (value) { + if (typeof value !== 'string') { + return null + } + const trimmed = value.trim() + if (!trimmed) { + return null + } + if (YOUTUBE_ID.test(trimmed)) { + return { provider: 'youtube', id: trimmed } + } + let url + try { + url = new URL(trimmed) + } catch (_err) { + return null + } + if (url.protocol !== 'https:' && url.protocol !== 'http:') { + return null + } + const host = url.hostname.replace(/^www\./, '') + let candidate = null + if (host === 'youtu.be') { + candidate = url.pathname.slice(1) + } else if (host === 'youtube.com' || host === 'm.youtube.com' || host === 'youtube-nocookie.com') { + if (url.pathname === '/watch') { + candidate = url.searchParams.get('v') + } else if (url.pathname.startsWith('/embed/')) { + candidate = url.pathname.slice('/embed/'.length) + } else if (url.pathname.startsWith('/shorts/')) { + candidate = url.pathname.slice('/shorts/'.length) + } else if (url.pathname.startsWith('/live/')) { + candidate = url.pathname.slice('/live/'.length) + } + } + if (candidate && YOUTUBE_ID.test(candidate)) { + return { provider: 'youtube', id: candidate } + } + return null +} + +// A path rooted at the platform itself. Excludes '//host' and '/\\host', which +// browsers resolve to another origin despite starting with a slash. +const IN_APP_PATH = /^\/(?![/\\])/ +// The URL parser strips these before parsing, so '//host' reaches the +// browser as '//host'. Nothing legitimate needs them, so refuse them outright +// rather than trying to normalise. +const STRIPPED_BY_URL_PARSER = /[\t\n\r]/ + +/** + * Validate a link an admin wants a recipient to follow. + * + * Accepts an absolute http(s) URL, or a path within the platform. Anything + * else, notably `javascript:` and `data:`, and anything that looks like a path + * but resolves to another origin, is rejected. + * + * @param {string} value + * @returns {string|null} the link, or null when it is not safe to render + */ +function parseLinkUrl (value) { + if (typeof value !== 'string') { + return null + } + const target = value.trim() + if (!target || STRIPPED_BY_URL_PARSER.test(target)) { + return null + } + let url = null + try { + // No base: an absolute url parses, anything relative throws + url = new URL(target) + } catch (_err) { + url = null + } + if (url) { + return (url.protocol === 'https:' || url.protocol === 'http:') ? target : null + } + return IN_APP_PATH.test(target) ? target : null +} + +/** + * Validate an admin-supplied call-to-action. + * + * @param {{label: string, url: string}} value + * @returns {{label: string, url: string}|null} null when incomplete, or the url is not safe to render + */ +function parseCallToAction (value) { + if (!value || typeof value !== 'object') { + return null + } + const label = typeof value.label === 'string' ? value.label.trim() : '' + const target = parseLinkUrl(typeof value.url === 'string' ? value.url : '') + if (!label || !target) { + return null + } + return { label, url: target } +} + +module.exports = { + parseVideoReference, + parseCallToAction, + parseLinkUrl +} diff --git a/forge/lib/teamFilters.js b/forge/lib/teamFilters.js new file mode 100644 index 0000000000..8dce048628 --- /dev/null +++ b/forge/lib/teamFilters.js @@ -0,0 +1,44 @@ +const { Op } = require('sequelize') + +/** + * Build the sequelize where clause for an admin team listing from the request + * query, so every route that lists or counts teams applies the same filters. + * + * Recognised query properties: + * - teamType: comma separated team type hashids + * - state: 'suspended' for only suspended teams, 'active' to exclude them + * - billing: comma separated subscription statuses (ignored when filtering on + * suspended teams, and only when billing is available) + * + * @param {Object} app the forge app + * @param {Object} query the request query + * @returns {Object} a sequelize where clause + */ +function buildTeamFilterWhere (app, query = {}) { + const where = {} + const filters = [] + if (query.teamType) { + const teamTypes = query.teamType.split(',').map(app.db.models.TeamType.decodeHashid).flat() + filters.push({ TeamTypeId: { [Op.in]: teamTypes } }) + } + if (query.state === 'suspended') { + filters.push({ suspended: true }) + } else { + const excludeSuspended = query.state === 'active' || !!(app.billing && query.billing) + if (excludeSuspended) { + filters.push({ suspended: false }) + } + if (app.billing && query.billing) { + const billingStates = query.billing.split(',') + filters.push({ '$Subscription.status$': { [Op.in]: billingStates } }) + } + } + if (filters.length > 0) { + where[Op.and] = filters + } + return where +} + +module.exports = { + buildTeamFilterWhere +} diff --git a/forge/routes/api/admin.js b/forge/routes/api/admin.js index b12b0b2486..fcb75484f0 100644 --- a/forge/routes/api/admin.js +++ b/forge/routes/api/admin.js @@ -1,6 +1,13 @@ const { Op } = require('sequelize') +const { parseCallToAction, parseLinkUrl, parseVideoReference } = require('../../lib/announcements.js') const { Roles } = require('../../lib/roles.js') +const { buildTeamFilterWhere } = require('../../lib/teamFilters.js') + +// The most teams a single announcement can be addressed to explicitly. Also +// caps the id list returned for a select-all, so a large platform cannot turn +// one click into an unbounded response. +const MAX_AUDIENCE_TEAMS = 10000 module.exports = async function (app) { async function getStats () { @@ -444,6 +451,47 @@ module.exports = async function (app) { reply.send({ status: 'okay' }) }) + /** + * The ids of every team matching a search and filter. + * + * The team list is paginated, so an admin building an audience out of + * hundreds of teams cannot get the whole matching set from it. This returns + * ids only, for the same filters, in one request. + */ + app.get('/teams/ids', { + preHandler: app.needsPermission('team:list'), + schema: { + summary: 'Get the ids of all teams matching a filter - admin-only', + tags: ['Platform', 'Teams'], + query: { + type: 'object', + properties: { + query: { type: 'string' }, + teamType: { type: 'string' }, + state: { type: 'string' }, + billing: { type: 'string' } + } + }, + response: { + 200: { + type: 'object', + properties: { + count: { type: 'number' }, + truncated: { type: 'boolean' }, + ids: { type: 'array', items: { type: 'string' } } + } + }, + '4xx': { + $ref: 'APIError' + } + } + } + }, async (request, reply) => { + const where = buildTeamFilterWhere(app, request.query) + const result = await app.db.models.Team.getAllIds({ query: request.query.query }, where, MAX_AUDIENCE_TEAMS) + reply.send(result) + }) + app.post('/announcements', { preHandler: app.needsPermission('user:announcements:manage'), schema: { @@ -453,17 +501,29 @@ module.exports = async function (app) { type: 'object', required: ['message', 'title', 'filter'], properties: { - message: { type: 'string' }, - title: { type: 'string' }, + message: { type: 'string', maxLength: 4000 }, + title: { type: 'string', maxLength: 120 }, filter: { type: 'object', properties: { - roles: { type: 'array', items: { type: 'number' } } + roles: { type: 'array', items: { type: 'number' } }, + teamTypes: { type: 'array', items: { type: 'string' } }, + teams: { type: 'array', items: { type: 'string' }, maxItems: MAX_AUDIENCE_TEAMS }, + billing: { type: 'array', items: { type: 'string' } } } }, mock: { type: 'boolean' }, to: { type: 'object' }, - url: { type: 'string' } + url: { type: 'string', maxLength: 500 }, + format: { type: 'string', enum: ['plain', 'markdown'] }, + video: { type: 'string', maxLength: 300 }, + cta: { + type: 'object', + properties: { + label: { type: 'string', maxLength: 40 }, + url: { type: 'string', maxLength: 500 } + } + } } }, response: { @@ -486,7 +546,10 @@ module.exports = async function (app) { filter, mock, to, - url + url, + format, + video, + cta } = request.body const recipientRoles = filter?.roles @@ -497,14 +560,40 @@ module.exports = async function (app) { if (filter?.teamTypes && filter.teamTypes.length > 0) { teamTypes = filter.teamTypes.map(app.db.models.TeamType.decodeHashid).flat() } + let teams + if (filter?.teams && filter.teams.length > 0) { + const decoded = filter.teams.map(hashid => app.db.models.Team.decodeHashid(hashid)).flat() + // decodeHashid is lenient - anything that is not a real id is rejected + // here rather than quietly narrowing the audience to nothing. + if (decoded.length !== filter.teams.length || decoded.some(id => !Number.isInteger(id) || id <= 0)) { + return reply.code(400).send({ code: 'bad_request', error: 'Invalid team provided.' }) + } + teams = decoded + } let billing if (filter?.billing && filter.billing.length > 0) { billing = filter.billing } + if (video && !parseVideoReference(video)) { + return reply.code(400).send({ code: 'bad_request', error: 'Unsupported video link. Provide a YouTube URL.' }) + } + if (cta && Object.keys(cta).length > 0 && !parseCallToAction(cta)) { + return reply.code(400).send({ code: 'bad_request', error: 'A button needs both a label and an http(s) or in-app url.' }) + } + // The whole-card link on a plain announcement ends up in an href too, so + // it gets the same treatment as the button + if (url && !parseLinkUrl(url)) { + return reply.code(400).send({ code: 'bad_request', error: 'The URL link must be an http(s) or in-app url.' }) + } + // `to` is the same sink by another name: the front-end prefers it over + // `url` and opens `to.url` directly + if (to && typeof to.url === 'string' && !parseLinkUrl(to.url)) { + return reply.code(400).send({ code: 'bad_request', error: 'The notification link must be an http(s) or in-app url.' }) + } if (mock) { // If mock is sent, return an indication of how many users would receive this notification // without actually sending them. - const count = await app.db.models.User.byTeamRole(recipientRoles, { teamTypes, billing, summary: true, count: true }) + const count = await app.db.models.User.byTeamRole(recipientRoles, { teamTypes, teams, billing, summary: true, count: true }) reply.send({ mock: true, recipientCount: count @@ -512,12 +601,22 @@ module.exports = async function (app) { return } - const recipients = await app.db.models.User.byTeamRole(recipientRoles, { teamTypes, billing, summary: true }) + const recipients = await app.db.models.User.byTeamRole(recipientRoles, { teamTypes, teams, billing, summary: true }) const notificationType = 'announcement' const titleSlug = title.replace(/[^a-zA-Z0-9-]/g, '-').toLowerCase() const uniqueId = Date.now().toString(36) + Math.random().toString(36).substring(2) const reference = `${uniqueId}:${titleSlug}` - const data = { title, message, ...(to && { to }), ...(url && { url }) } + const videoReference = parseVideoReference(video) + const callToAction = parseCallToAction(cta) + const data = { + title, + message, + ...(format === 'markdown' && { format }), + ...(videoReference && { video: videoReference }), + ...(callToAction && { cta: callToAction }), + ...(to && { to }), + ...(url && { url: parseLinkUrl(url) }) + } await app.notifications.sendBulk( recipients, notificationType, diff --git a/forge/routes/api/team.js b/forge/routes/api/team.js index d3b08d55dd..604fa3d251 100644 --- a/forge/routes/api/team.js +++ b/forge/routes/api/team.js @@ -1,8 +1,7 @@ const crypto = require('crypto') -const { Op } = require('sequelize') - const { Roles } = require('../../lib/roles') +const { buildTeamFilterWhere } = require('../../lib/teamFilters') const teamShared = require('./shared/team.js') const TeamDevices = require('./teamDevices.js') @@ -203,22 +202,7 @@ module.exports = async function (app) { } }, async (request, reply) => { // Admin request for all teams - const where = {} - const filters = [] - if (request.query.teamType) { - const teamTypes = request.query.teamType.split(',').map(app.db.models.TeamType.decodeHashid).flat() - filters.push({ TeamTypeId: { [Op.in]: teamTypes } }) - } - if (request.query.state === 'suspended') { - filters.push({ suspended: true }) - } else if (app.billing && request.query.billing) { - filters.push({ suspended: false }) - const billingStates = request.query.billing.split(',') - filters.push({ '$Subscription.status$': { [Op.in]: billingStates } }) - } - if (filters.length > 0) { - where[Op.and] = filters - } + const where = buildTeamFilterWhere(app, request.query) const paginationOptions = app.getPaginationOptions(request) const teams = await app.db.models.Team.getAll(paginationOptions, where) teams.teams = teams.teams.map(t => app.db.views.Team.team(t)) diff --git a/frontend/src/api/admin.js b/frontend/src/api/admin.js index 35cc208d3d..c61e00cdcb 100644 --- a/frontend/src/api/admin.js +++ b/frontend/src/api/admin.js @@ -77,8 +77,27 @@ const getAnnouncementNotifications = async () => { }) } -const sendAnnouncementNotification = async ({ title, message, filter, mock, to, url }) => { - return client.post('/api/v1/admin/announcements', { message, title, filter, mock, to, url }) +/** + * The ids of every team matching a search and filter, for building an + * announcement audience out of more teams than a page of the list holds. + */ +const getTeamIdsForFilter = async (query, filter = {}) => { + const params = new URLSearchParams() + if (query) { + params.set('query', query) + } + Object.entries(filter).forEach(([key, value]) => { + if (Array.isArray(value) ? value.length : value) { + params.set(key, Array.isArray(value) ? value.join(',') : value) + } + }) + const suffix = params.toString() + return client.get('/api/v1/admin/teams/ids' + (suffix ? '?' + suffix : '')) + .then(res => res.data) +} + +const sendAnnouncementNotification = async ({ title, message, filter, mock, to, url, format, video, cta }) => { + return client.post('/api/v1/admin/announcements', { message, title, filter, mock, to, url, format, video, cta }) .then(res => { return res.data }) @@ -99,5 +118,6 @@ export default { generateExpertAgentCreds, deleteExpertAgentCreds, getAnnouncementNotifications, + getTeamIdsForFilter, sendAnnouncementNotification } diff --git a/frontend/src/components/drawers/notifications/NotificationsDrawer.vue b/frontend/src/components/drawers/notifications/NotificationsDrawer.vue index f82180b80a..477e33eea3 100644 --- a/frontend/src/components/drawers/notifications/NotificationsDrawer.vue +++ b/frontend/src/components/drawers/notifications/NotificationsDrawer.vue @@ -80,6 +80,7 @@ import { markRaw } from 'vue' import userAPI from '../../../api/user.js' import alerts from '../../../services/alerts.js' +import AnnouncementNotification from '../../notifications/Announcement.vue' import GenericNotification from '../../notifications/Generic.vue' import TeamInvitationAcceptedNotification from '../../notifications/invitations/Accepted.vue' @@ -131,6 +132,13 @@ export default { this.closeRightDrawer() }, getNotificationsComponent (notification) { + if (notification.type === 'announcement') { + // Rich announcements own their click targets (links, video, + // button). A plain one stays a single card-wide link. + return this.isRichAnnouncement(notification) + ? markRaw(AnnouncementNotification) + : markRaw(GenericNotification) + } let comp = this.componentCache[notification.type] if (comp) { return comp @@ -151,6 +159,10 @@ export default { this.componentCache[notification.type] = comp return comp }, + isRichAnnouncement (notification) { + const data = notification.data || {} + return data.format === 'markdown' || !!data.video || !!data.cta + }, onSelected (notification) { this.selections.push(notification) }, diff --git a/frontend/src/components/notifications/Announcement.vue b/frontend/src/components/notifications/Announcement.vue new file mode 100644 index 0000000000..8ee12b722a --- /dev/null +++ b/frontend/src/components/notifications/Announcement.vue @@ -0,0 +1,101 @@ + + + + + diff --git a/frontend/src/components/notifications/announcements/AnnouncementBody.vue b/frontend/src/components/notifications/announcements/AnnouncementBody.vue new file mode 100644 index 0000000000..30b38b7460 --- /dev/null +++ b/frontend/src/components/notifications/announcements/AnnouncementBody.vue @@ -0,0 +1,247 @@ +