Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions forge/db/models/Team.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
19 changes: 12 additions & 7 deletions forge/db/models/User.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -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()) }
}
}
}
Expand Down
124 changes: 124 additions & 0 deletions forge/lib/announcements.js
Original file line number Diff line number Diff line change
@@ -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 '/<tab>/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
}
44 changes: 44 additions & 0 deletions forge/lib/teamFilters.js
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading