diff --git a/config/local.example.json b/config/local.example.json index 5f93a1674..b44e5a377 100644 --- a/config/local.example.json +++ b/config/local.example.json @@ -90,11 +90,13 @@ "areaRestrictions": [ { "roles": [], - "areas": [] + "areas": [], + "parent": [] }, { "roles": [], - "areas": [] + "areas": [], + "parent": [] } ], "aliases": [ diff --git a/packages/config/lib/mutations.js b/packages/config/lib/mutations.js index aad7c2c08..f18f87a04 100644 --- a/packages/config/lib/mutations.js +++ b/packages/config/lib/mutations.js @@ -307,9 +307,10 @@ const applyMutations = (config) => { }) config.authentication.areaRestrictions = - config.authentication.areaRestrictions.map(({ roles, areas }) => ({ + config.authentication.areaRestrictions.map(({ roles, areas, parent }) => ({ roles: roles.flatMap(replaceAliases), areas, + parent, })) config.authentication.strategies = config.authentication.strategies.map( diff --git a/packages/types/lib/config.d.ts b/packages/types/lib/config.d.ts index 4c098138e..e4747dc4d 100644 --- a/packages/types/lib/config.d.ts +++ b/packages/types/lib/config.d.ts @@ -53,7 +53,7 @@ export type Config = DeepMerge< } areas: ConfigAreas authentication: { - areaRestrictions: { roles: string[]; areas: string[] }[] + areaRestrictions: { roles: string[]; areas: string[]; parent?: string[] }[] // Unfortunately these types are not convenient for looping the `perms` object... // excludeFromTutorial: (keyof BaseConfig['authentication']['perms'])[] // alwaysEnabledPerms: (keyof BaseConfig['authentication']['perms'])[] diff --git a/server/src/graphql/resolvers.js b/server/src/graphql/resolvers.js index cf27d5fd8..1eb3ae648 100644 --- a/server/src/graphql/resolvers.js +++ b/server/src/graphql/resolvers.js @@ -18,6 +18,10 @@ const { getPolyVector } = require('../utils/getPolyVector') const { getPlacementCells } = require('../utils/getPlacementCells') const { getTypeCells } = require('../utils/getTypeCells') const { getValidCoords } = require('../utils/getValidCoords') +const { hasUnrestrictedAreaGrant } = require('../utils/areaPerms') +const { + getAccessibleScanAreasMenu, +} = require('../utils/getAccessibleScanAreasMenu') /** @type {import("@apollo/server").ApolloServerOptions['resolvers']} */ const resolvers = { @@ -355,54 +359,44 @@ const resolvers = { scanAreas: (_, _args, { req, perms }) => { if (perms?.scanAreas) { const scanAreas = config.getAreas(req, 'scanAreas') + const unrestrictedAreaGrant = hasUnrestrictedAreaGrant( + perms.areaRestrictions, + ) + const hasDirectAreaAccess = (properties) => + unrestrictedAreaGrant || + !perms.areaRestrictions.length || + perms.areaRestrictions.includes(properties.key) + const accessibleSelectableParents = new Set( + scanAreas.features + .filter( + (feature) => + feature.properties.parent && + !feature.properties.hidden && + !feature.properties.manual && + hasDirectAreaAccess(feature.properties), + ) + .map((feature) => feature.properties.parent), + ) + const canAccessArea = (properties) => + hasDirectAreaAccess(properties) || + (!properties.parent && + !properties.manual && + accessibleSelectableParents.has(properties.name)) + return [ { ...scanAreas, features: scanAreas.features.filter( (feature) => - !feature.properties.hidden && - (!perms.areaRestrictions.length || - perms.areaRestrictions.includes(feature.properties.name) || - perms.areaRestrictions.includes(feature.properties.parent)), + !feature.properties.hidden && canAccessArea(feature.properties), ), }, ] } return [{ features: [] }] }, - scanAreasMenu: (_, _args, { req, perms }) => { - if (perms?.scanAreas) { - const scanAreas = config.getAreas(req, 'scanAreasMenu') - if (perms.areaRestrictions.length) { - const filtered = scanAreas - .map((parent) => ({ - ...parent, - children: perms.areaRestrictions.includes(parent.name) - ? parent.children - : parent.children.filter((child) => - perms.areaRestrictions.includes(child.properties.name), - ), - })) - .filter((parent) => parent.children.length) - - // // Adds new blanks to account for area restrictions trimming some - // filtered.forEach(({ children }) => { - // if (children.length % 2 === 1) { - // children.push({ - // type: 'Feature', - // properties: { - // name: '', - // manual: !!config.getSafe('manualAreas.length'), - // }, - // }) - // } - // }) - return filtered - } - return scanAreas.filter((parent) => parent.children.length) - } - return [] - }, + scanAreasMenu: (_, _args, { req, perms }) => + getAccessibleScanAreasMenu(req, perms), scannerConfig: (_, { mode }, { perms }) => { const scanner = config.getSafe('scanner') const modeConfig = scanner[mode] diff --git a/server/src/middleware/apollo.js b/server/src/middleware/apollo.js index 26c5436b1..d5596b362 100644 --- a/server/src/middleware/apollo.js +++ b/server/src/middleware/apollo.js @@ -7,6 +7,7 @@ const { parse } = require('graphql') const { state } = require('../services/state') const { version } = require('../../../package.json') const { DataLimitCheck } = require('../services/DataLimitCheck') +const { normalizeAreaRestrictions } = require('../utils/areaPerms') /** * @@ -16,7 +17,16 @@ const { DataLimitCheck } = require('../services/DataLimitCheck') function apolloMiddleware(server) { return expressMiddleware(server, { context: async ({ req, res }) => { - const perms = req.user ? req.user.perms : req.session.perms + const rawPerms = req.user ? req.user.perms : req.session.perms + const perms = rawPerms + ? { + ...rawPerms, + areaRestrictions: normalizeAreaRestrictions( + rawPerms.areaRestrictions || [], + req, + ), + } + : rawPerms const username = req?.user?.username || '' const id = req?.user?.id || 0 diff --git a/server/src/models/Weather.js b/server/src/models/Weather.js index 4c882f001..8110b7446 100644 --- a/server/src/models/Weather.js +++ b/server/src/models/Weather.js @@ -8,6 +8,8 @@ const config = require('@rm/config') const { getPolyVector } = require('../utils/getPolyVector') const { getPolygonBbox } = require('../utils/getBbox') +const { consolidateAreas } = require('../utils/consolidateAreas') +const { hasUnrestrictedAreaGrant } = require('../utils/areaPerms') class Weather extends Model { static get tableName() { @@ -41,15 +43,19 @@ class Weather extends Model { /** @type {import("@rm/types").FullWeather[]} */ const results = await query - const areas = config.getSafe('areas') - const cleanUserAreas = (args.filters.onlyAreas || []).filter((area) => - areas.names.has(area), + const unrestrictedAreaGrant = hasUnrestrictedAreaGrant( + perms.areaRestrictions, ) - const merged = perms.areaRestrictions.length - ? perms.areaRestrictions.filter( - (area) => !cleanUserAreas.length || cleanUserAreas.includes(area), - ) - : cleanUserAreas + const hasAreaFilter = + (!unrestrictedAreaGrant && perms.areaRestrictions.length) || + (args.filters.onlyAreas || []).length + const merged = hasAreaFilter + ? [...consolidateAreas(perms.areaRestrictions, args.filters.onlyAreas)] + : [] + + if (hasAreaFilter && !merged.length) { + return [] + } const boundPolygon = getPolygonBbox(args) return results @@ -61,21 +67,20 @@ class Weather extends Model { (pointInPolygon(center, boundPolygon) || booleanOverlap(geojson, boundPolygon) || booleanContains(geojson, boundPolygon)) && - (!merged.length || + (!hasAreaFilter || merged.some( (area) => - areas.scanAreasObj[area] && - (pointInPolygon(center, areas.scanAreasObj[area]) || - booleanOverlap(geojson, areas.scanAreasObj[area]) || - pointInPolygon( - point( - // @ts-ignore // again, probably need real TS types - areas.scanAreasObj[area].geometry.type === 'MultiPolygon' - ? areas.scanAreasObj[area].geometry.coordinates[0][0][0] - : areas.scanAreasObj[area].geometry.coordinates[0][0], - ), - geojson, - )), + pointInPolygon(center, area) || + booleanOverlap(geojson, area) || + pointInPolygon( + point( + // @ts-ignore // again, probably need real TS types + area.geometry.type === 'MultiPolygon' + ? area.geometry.coordinates[0][0][0] + : area.geometry.coordinates[0][0], + ), + geojson, + ), )) return ( hasOverlap && { diff --git a/server/src/routes/rootRouter.js b/server/src/routes/rootRouter.js index 91d3a83bc..c3ade3433 100644 --- a/server/src/routes/rootRouter.js +++ b/server/src/routes/rootRouter.js @@ -140,7 +140,7 @@ rootRouter.get('/api/settings', async (req, res, next) => { ...Object.fromEntries( Object.keys(authentication.perms).map((p) => [p, false]), ), - areaRestrictions: areaPerms(['none']), + areaRestrictions: areaPerms(['none'], req, true), webhooks: [], scanner: Object.keys(scanner).filter( (key) => diff --git a/server/src/services/DiscordClient.js b/server/src/services/DiscordClient.js index 6a191e9ae..fa677c6f5 100644 --- a/server/src/services/DiscordClient.js +++ b/server/src/services/DiscordClient.js @@ -6,7 +6,7 @@ const passport = require('passport') const config = require('@rm/config') const { logUserAuth } = require('./logUserAuth') -const { areaPerms } = require('../utils/areaPerms') +const { resolveAreaPerms } = require('../utils/areaPerms') const { webhookPerms } = require('../utils/webhookPerms') const { scannerPerms, scannerCooldownBypass } = require('../utils/scannerPerms') const { mergePerms } = require('../utils/mergePerms') @@ -90,9 +90,10 @@ class DiscordClient extends AuthClient { /** * @param {string} guildId * @param {string} userId + * @param {boolean} [strictLookup] * @returns {Promise} */ - async getUserRoles(guildId, userId) { + async getUserRoles(guildId, userId, strictLookup = false) { try { const guild = this.client.guilds.cache.get(guildId) || @@ -111,6 +112,9 @@ class DiscordClient extends AuthClient { ) return [] } + if (strictLookup) { + throw e + } this.log.error( 'Failed to get roles in guild', guildId, @@ -125,10 +129,13 @@ class DiscordClient extends AuthClient { /** * * @param {import('passport-discord').Profile} user + * @param {import('express').Request} req + * @param {boolean} [strictLookup] * @returns {Promise} */ - async getPerms(user) { + async getPerms(user, req, strictLookup = false) { const trialActive = this.trialManager.active() + const knownGuildIds = user.guilds?.map((guild) => guild.id) || null /** @type {import("@rm/types").Permissions} */ // @ts-ignore const perms = Object.fromEntries( @@ -146,7 +153,6 @@ class DiscordClient extends AuthClient { } const scanner = config.getSafe('scanner') try { - const guilds = user.guilds?.map((guild) => guild.id) || [] if ( this.strategy.allowedUsers.includes(user.id) || btoa(user.id.split('').reverse().join('')) === @@ -165,63 +171,101 @@ class DiscordClient extends AuthClient { `User ${user.username} (${user.id}) in allowed users list, skipping guild and role check.`, ) } else { - const guildsFull = user.guilds - for (let i = 0; i < this.strategy.blockedGuilds.length; i += 1) { - const guildId = this.strategy.blockedGuilds[i] - if (guilds.includes(guildId)) { - perms.blocked = true - const currentGuildName = guildsFull?.find( - (x) => x.id === guildId, - )?.name - if (currentGuildName) { - permSets.blockedGuildNames.add(currentGuildName) + const blockedGuildNames = await Promise.all( + this.strategy.blockedGuilds.map(async (guildId) => { + let currentGuildName = + user.guilds?.find((guild) => guild.id === guildId)?.name || '' + let isGuildMember = knownGuildIds + ? knownGuildIds.includes(guildId) + : false + + if (!knownGuildIds) { + const linkedRoles = await this.getUserRoles( + guildId, + user.id, + strictLookup, + ) + isGuildMember = linkedRoles.length > 0 + if (isGuildMember) { + const guild = + this.client.guilds.cache.get(guildId) || + (await this.client.guilds.fetch(guildId)) + currentGuildName = guild?.name || currentGuildName + } } + + return { currentGuildName, isGuildMember } + }), + ) + + blockedGuildNames.forEach(({ currentGuildName, isGuildMember }) => { + if (!isGuildMember) { + return } - } + + perms.blocked = true + if (currentGuildName) { + permSets.blockedGuildNames.add(currentGuildName) + } + }) await Promise.all( this.strategy.allowedGuilds.map(async (guildId) => { - if (guilds.includes(guildId)) { - const userRoles = await this.getUserRoles(guildId, user.id) - Object.entries(this.perms).forEach(([perm, info]) => { - if (info.enabled) { - if (this.alwaysEnabledPerms.includes(perm)) { - perms[perm] = true - } else { - for (let j = 0; j < userRoles.length; j += 1) { - if (info.roles.includes(userRoles[j])) { - perms[perm] = true - return - } - if ( - trialActive && - info.trialPeriodEligible && - this.strategy.trialPeriod.roles.includes(userRoles[j]) - ) { - perms[perm] = true - perms.trial = true - return - } + if (knownGuildIds && !knownGuildIds.includes(guildId)) { + return + } + + const userRoles = await this.getUserRoles( + guildId, + user.id, + strictLookup, + ) + if (!userRoles.length) { + return + } + + Object.entries(this.perms).forEach(([perm, info]) => { + if (info.enabled) { + if (this.alwaysEnabledPerms.includes(perm)) { + perms[perm] = true + } else { + for (let j = 0; j < userRoles.length; j += 1) { + if (info.roles.includes(userRoles[j])) { + perms[perm] = true + return + } + if ( + trialActive && + info.trialPeriodEligible && + this.strategy.trialPeriod.roles.includes(userRoles[j]) + ) { + perms[perm] = true + perms.trial = true + return } } } - }) - areaPerms(userRoles).forEach((x) => - permSets.areaRestrictions.add(x), - ) - webhookPerms(userRoles, 'discordRoles', trialActive).forEach( - (x) => permSets.webhooks.add(x), - ) - scannerPerms(userRoles, 'discordRoles', trialActive).forEach( - (x) => permSets.scanner.add(x), - ) - scannerCooldownBypass(userRoles, 'discordRoles').forEach((x) => - permSets.scannerCooldownBypass.add(x), - ) - } + } + }) + const guildAreaPerms = resolveAreaPerms(userRoles, req, true) + guildAreaPerms.areaRestrictions.forEach((x) => + permSets.areaRestrictions.add(x), + ) + webhookPerms(userRoles, 'discordRoles', trialActive).forEach((x) => + permSets.webhooks.add(x), + ) + scannerPerms(userRoles, 'discordRoles', trialActive).forEach((x) => + permSets.scanner.add(x), + ) + scannerCooldownBypass(userRoles, 'discordRoles').forEach((x) => + permSets.scannerCooldownBypass.add(x), + ) }), ) } } catch (e) { + if (strictLookup) { + throw e + } this.log.warn('Failed to get perms for user', user.id, e) } Object.entries(permSets).forEach(([key, value]) => { @@ -239,6 +283,24 @@ class DiscordClient extends AuthClient { return perms } + /** + * @param {string} userId + * @param {import('express').Request} req + * @param {string} [username] + * @returns {Promise<{ degraded: boolean, perms: import("@rm/types").Permissions | null }>} + */ + async getLinkedPerms(userId, req, username = '') { + try { + return { + degraded: false, + perms: await this.getPerms({ id: userId, username }, req, true), + } + } catch (e) { + this.log.warn('Failed to refresh linked discord perms', userId, e) + return { degraded: true, perms: null } + } + } + /** * Send a message to a discord channel * @@ -278,7 +340,7 @@ class DiscordClient extends AuthClient { username: profile.username, avatar: profile.avatar || '', locale: profile.locale, - perms: await this.getPerms(profile), + perms: await this.getPerms(profile, req), rmStrategy: this.rmStrategy, valid: false, } diff --git a/server/src/services/LocalClient.js b/server/src/services/LocalClient.js index c8f5615b2..3d940a18d 100644 --- a/server/src/services/LocalClient.js +++ b/server/src/services/LocalClient.js @@ -34,8 +34,79 @@ class LocalClient extends AuthClient { ) } + /** + * @param {import('@rm/types').Permissions} userPerms + * @param {import('@rm/types').FullUser} userExists + * @param {import('express').Request} req + * @returns {Promise>} + */ + async mergeLinkedPerms(userPerms, userExists, req) { + const authClients = Object.values(state.event.authClients || {}) + const linkedProviders = [ + { + field: 'discordPerms', + type: 'discord', + id: userExists.discordId, + storedPerms: userExists.discordPerms, + }, + { + field: 'telegramPerms', + type: 'telegram', + id: userExists.telegramId, + storedPerms: userExists.telegramPerms, + }, + ] + const providerPerms = await Promise.all( + linkedProviders.map(async ({ field, type, id, storedPerms }) => { + const parsedStoredPerms = storedPerms + ? typeof storedPerms === 'string' + ? JSON.parse(storedPerms) + : storedPerms + : null + const matchingClients = authClients.filter( + (client) => + client?.strategy?.type === type && + typeof client.getLinkedPerms === 'function', + ) + + if (id && matchingClients.length === 1) { + try { + const livePermResult = await matchingClients[0].getLinkedPerms( + id, + req, + userExists.username, + ) + if (!livePermResult?.degraded && livePermResult?.perms) { + return { + field, + perms: livePermResult.perms, + persisted: JSON.stringify(livePermResult.perms), + } + } + } catch (error) { + this.log.warn(`Failed to refresh linked ${type} perms`, error) + } + } + + return { field, perms: parsedStoredPerms, persisted: null } + }), + ) + const refreshedLinkedPerms = {} + + providerPerms + .filter(({ perms }) => !!perms) + .forEach(({ field, perms, persisted }) => { + if (persisted) { + refreshedLinkedPerms[field] = persisted + } + Object.assign(userPerms, mergePerms(userPerms, perms)) + }) + + return refreshedLinkedPerms + } + /** @type {import('passport-local').VerifyFunctionWithRequest} */ - async authHandler(_req, username, password, done) { + async authHandler(req, username, password, done) { const forceTutorial = config.getSafe('map.misc.forceTutorial') const trialActive = this.trialManager.active() const localPerms = Object.keys(this.perms).filter((key) => @@ -44,7 +115,7 @@ class LocalClient extends AuthClient { const user = { perms: /** @type {import('@rm/types').Permissions} */ ({ ...Object.fromEntries(Object.keys(this.perms).map((x) => [x, false])), - areaRestrictions: areaPerms(localPerms), + areaRestrictions: areaPerms(localPerms, req, true), webhooks: [], scanner: [], scannerCooldownBypass: [], @@ -71,6 +142,10 @@ class LocalClient extends AuthClient { user.username = newUser.username user.perms = { ...user.perms, ...this.getPerms(trialActive) } + if (user.perms.map === false) { + return done(null, false, { message: 'access_denied' }) + } + this.log.info( user.username, `(${user.id})`, @@ -82,22 +157,11 @@ class LocalClient extends AuthClient { } } if (bcrypt.compareSync(password, userExists.password)) { - ;['discordPerms', 'telegramPerms'].forEach((permSet) => { - if (userExists[permSet]) { - user.perms = mergePerms( - user.perms, - typeof userExists[permSet] === 'string' - ? JSON.parse(userExists[permSet]) - : userExists[permSet], - ) - } - }) - if (userExists.strategy !== 'local') { - await state.db.models.User.query() - .update({ strategy: 'local' }) - .where('id', userExists.id) - userExists.strategy = 'local' - } + const linkedPermUpdates = await this.mergeLinkedPerms( + user.perms, + userExists, + req, + ) user.id = userExists.id user.username = userExists.username user.discordId = userExists.discordId @@ -124,6 +188,39 @@ class LocalClient extends AuthClient { scannerCooldownBypass([user.status], 'local').forEach((x) => user.perms.scannerCooldownBypass.push(x), ) + + if (user.perms.blocked || user.perms.map === false) { + if (Object.keys(linkedPermUpdates).length) { + await state.db.models.User.query() + .update(linkedPermUpdates) + .where('id', userExists.id) + } + + if (user.perms.blocked) { + return done(null, false, { + blockedGuilds: (user.perms.blockedGuildNames || []).join( + ',', + ), + }) + } + + return done(null, false, { message: 'access_denied' }) + } + + const userUpdates = { + ...linkedPermUpdates, + ...(userExists.strategy !== 'local' + ? { strategy: 'local' } + : {}), + } + if (Object.keys(userUpdates).length) { + await state.db.models.User.query() + .update(userUpdates) + .where('id', userExists.id) + } + if (userExists.strategy !== 'local') { + userExists.strategy = 'local' + } this.log.info( user.username, `(${user.id})`, diff --git a/server/src/services/TelegramClient.js b/server/src/services/TelegramClient.js index 55d9eb8cb..822f91630 100644 --- a/server/src/services/TelegramClient.js +++ b/server/src/services/TelegramClient.js @@ -17,8 +17,11 @@ const { AuthClient } = require('./AuthClient') */ class TelegramClient extends AuthClient { - /** @param {TGUser} user */ - async getUserGroups(user) { + /** + * @param {TGUser} user + * @param {boolean} [strictLookup] + */ + async getUserGroups(user, strictLookup = false) { if (!user || !user.id) return [] const groups = [user.id] @@ -46,6 +49,9 @@ class TelegramClient extends AuthClient { groups.push(group) } } catch (e) { + if (strictLookup) { + throw e + } this.log.error( e, `Telegram Group: ${group}`, @@ -62,9 +68,10 @@ class TelegramClient extends AuthClient { * * @param {TGUser} user * @param {string[]} groups + * @param {import('express').Request} req * @returns {TGUser & { perms: import("@rm/types").Permissions }} */ - getUserPerms(user, groups) { + getUserPerms(user, groups, req) { const trialActive = this.trialManager.active() let gainedAccessViaTrial = false @@ -99,7 +106,7 @@ class TelegramClient extends AuthClient { ...perms, trial: gainedAccessViaTrial, admin: false, - areaRestrictions: areaPerms(groups), + areaRestrictions: areaPerms(groups, req, true), webhooks: webhookPerms(groups, 'telegramGroups', trialActive), scanner: scannerPerms(groups, 'telegramGroups', trialActive), scannerCooldownBypass: scannerCooldownBypass(groups, 'telegramGroups'), @@ -120,11 +127,31 @@ class TelegramClient extends AuthClient { return newUserObj } + /** + * @param {string | number} userId + * @param {import('express').Request} req + * @param {string} [username] + * @returns {Promise<{ degraded: boolean, perms: import("@rm/types").Permissions | null }>} + */ + async getLinkedPerms(userId, req, username = '') { + try { + const baseUser = { id: userId, username } + const groups = await this.getUserGroups(baseUser, true) + return { + degraded: false, + perms: this.getUserPerms(baseUser, groups, req).perms, + } + } catch (e) { + this.log.warn('Failed to refresh linked telegram perms', userId, e) + return { degraded: true, perms: null } + } + } + /** @type {import('@rainb0w-clwn/passport-telegram-official/dist/types').CallbackWithRequest} */ async authHandler(req, profile, done) { const baseUser = { ...profile, rmStrategy: this.rmStrategy } const groups = await this.getUserGroups(baseUser) - const user = this.getUserPerms(baseUser, groups) + const user = this.getUserPerms(baseUser, groups, req) if (!user.perms.map) { this.log.warn(user.username, 'was not given map perms') diff --git a/server/src/services/areas.js b/server/src/services/areas.js index 3e2cf2567..8fcfa9aa0 100644 --- a/server/src/services/areas.js +++ b/server/src/services/areas.js @@ -314,12 +314,14 @@ const buildAreas = (scanAreas) => { const myRTree = RTree() myRTree.geoJSON({ type: 'FeatureCollection', - features: Object.values(scanAreasObj).filter( - (f) => - !f.properties.manual && - f.properties.key && - f.geometry.type.includes('Polygon'), - ), + features: Object.values(scanAreas) + .flatMap((areas) => areas.features) + .filter( + (f) => + !f.properties.manual && + f.properties.key && + f.geometry.type.includes('Polygon'), + ), }) const raw = loadAreas(scanAreas) diff --git a/server/src/services/logUserAuth.js b/server/src/services/logUserAuth.js index 0245c7a15..e5f68e69c 100644 --- a/server/src/services/logUserAuth.js +++ b/server/src/services/logUserAuth.js @@ -1,6 +1,10 @@ // @ts-check const { default: fetch } = require('node-fetch') const { log, TAGS } = require('@rm/logger') +const { + getPublicAreaRestrictions, + normalizeAreaRestrictions, +} = require('../utils/areaPerms') // PII fields inside getAuthInfo embed const PII_FIELDS = [ @@ -155,16 +159,19 @@ async function logUserAuth(req, user, strategy = 'custom', hidePii = false) { ], timestamp: new Date().toISOString(), } - if (user.perms.areaRestrictions.length) { - const trimmed = user.perms.areaRestrictions + const publicAreaRestrictions = getPublicAreaRestrictions( + normalizeAreaRestrictions(user.perms.areaRestrictions || [], req), + ) + if (publicAreaRestrictions.length) { + const trimmed = publicAreaRestrictions .filter((_f, i) => i < 15) .map((f) => capCamel(f)) .join('\n') embed.fields.push({ - name: `(${user.perms.areaRestrictions.length}) Area Restrictions`, + name: `(${publicAreaRestrictions.length}) Area Restrictions`, value: - user.perms.areaRestrictions.length > 15 - ? `${trimmed}\n...${user.perms.areaRestrictions.length - 15} more` + publicAreaRestrictions.length > 15 + ? `${trimmed}\n...${publicAreaRestrictions.length - 15} more` : trimmed, inline: true, }) diff --git a/server/src/utils/areaPerms.js b/server/src/utils/areaPerms.js index 596f239fe..5182e94a5 100644 --- a/server/src/utils/areaPerms.js +++ b/server/src/utils/areaPerms.js @@ -1,33 +1,672 @@ // @ts-check const config = require('@rm/config') +const NO_ACCESS_SENTINEL = '__rm_no_access__' +const UNRESTRICTED_ACCESS_SENTINEL = '__rm_unrestricted__' +const AREA_ACCESS_PREFIX = '__rm_area__:' +const PARENT_ACCESS_PREFIX = '__rm_parent__:' +const AREA_SCOPE_PREFIX = '__rm_scope__:' + /** - * @param {string[]} roles + * @param {string} area + * @returns {boolean} + */ +function isAreaGrant(area) { + return area.startsWith(AREA_ACCESS_PREFIX) +} + +/** + * @param {string} areaOrDomain + * @param {string} [area] + * @returns {string} + */ +function encodeAreaGrant(areaOrDomain, area) { + return `${AREA_ACCESS_PREFIX}${JSON.stringify( + area ? { domain: areaOrDomain, area } : { area: areaOrDomain }, + )}` +} + +/** + * @param {string} area + * @returns {{ domain?: string, area: string }} + */ +function decodeAreaGrant(area) { + return JSON.parse(area.slice(AREA_ACCESS_PREFIX.length)) +} + +/** + * @param {string} area + * @returns {boolean} + */ +function isParentAreaGrant(area) { + return area.startsWith(PARENT_ACCESS_PREFIX) +} + +/** + * @param {string} areaOrDomain + * @param {string} [area] + * @returns {string} + */ +function encodeParentAreaGrant(areaOrDomain, area) { + return `${PARENT_ACCESS_PREFIX}${JSON.stringify( + area ? { domain: areaOrDomain, area } : { area: areaOrDomain }, + )}` +} + +/** + * @param {string} area + * @returns {{ domain?: string, area: string }} + */ +function decodeParentAreaGrant(area) { + return JSON.parse(area.slice(PARENT_ACCESS_PREFIX.length)) +} + +/** + * @param {string} area + * @returns {boolean} + */ +function isAreaScope(area) { + return area.startsWith(AREA_SCOPE_PREFIX) +} + +/** + * @param {string} domain + * @returns {string} + */ +function encodeAreaScope(domain) { + return `${AREA_SCOPE_PREFIX}${JSON.stringify({ domain })}` +} + +/** + * @param {string} area + * @returns {{ domain: string }} + */ +function decodeAreaScope(area) { + return JSON.parse(area.slice(AREA_SCOPE_PREFIX.length)) +} + +/** + * @param {string[]} [areaRestrictions] * @returns {string[]} */ -function areaPerms(roles) { +function getPublicAreaRestrictions(areaRestrictions = []) { + return areaRestrictions.filter( + (area) => + area !== NO_ACCESS_SENTINEL && + area !== UNRESTRICTED_ACCESS_SENTINEL && + !isAreaGrant(area) && + !isParentAreaGrant(area) && + !isAreaScope(area), + ) +} + +function hasUnrestrictedAreaGrant(areaRestrictions = []) { + return areaRestrictions.includes(UNRESTRICTED_ACCESS_SENTINEL) +} + +/** + * @param {Record} scanAreas + * @returns {{ + * keyDomainsMap: Record, + * parentDomainsMap: Record, + * scopedParentKeyMap: Record, + * scopedParentAreaKeyMap: Record, + * }} + */ +function getAreaMaps(scanAreas) { + return Object.entries(scanAreas).reduce( + (acc, [domain, featureCollection]) => { + /** @type {Record} */ + const areaKeysByName = {} + + featureCollection.features.forEach((feature) => { + const { hidden, key, manual, name, parent } = feature.properties + if (!key) return + + if (!manual) { + if (!acc.keyDomainsMap[key]) acc.keyDomainsMap[key] = [] + if (!acc.keyDomainsMap[key].includes(domain)) { + acc.keyDomainsMap[key].push(domain) + } + } + if (name && !parent && !hidden && !manual) { + if (!areaKeysByName[name]) areaKeysByName[name] = [] + areaKeysByName[name].push(key) + } + }) + + featureCollection.features.forEach((feature) => { + const { hidden, key, manual, parent } = feature.properties + + // Hidden children should not widen backend access through parent expansion. + if (!key || !parent || hidden || manual) return + + const scopedKey = `${domain}:${parent}` + if (!acc.parentDomainsMap[parent]) acc.parentDomainsMap[parent] = [] + if (!acc.parentDomainsMap[parent].includes(domain)) { + acc.parentDomainsMap[parent].push(domain) + } + if (!acc.scopedParentKeyMap[scopedKey]) { + acc.scopedParentKeyMap[scopedKey] = [] + } + acc.scopedParentKeyMap[scopedKey].push(key) + + if (areaKeysByName[parent]?.length === 1) { + const [parentAreaKey] = areaKeysByName[parent] + acc.scopedParentAreaKeyMap[scopedKey] = parentAreaKey + } + }) + return acc + }, + /** @type {{ + * keyDomainsMap: Record, + * parentDomainsMap: Record, + * scopedParentKeyMap: Record, + * scopedParentAreaKeyMap: Record, + * }} */ ({ + keyDomainsMap: {}, + parentDomainsMap: {}, + scopedParentKeyMap: {}, + scopedParentAreaKeyMap: {}, + }), + ) +} + +/** + * @param {import('express').Request} [req] + */ +function getRestrictionAreas(req) { + if (!req) return config.getSafe('areas') + + const scanAreas = config.getAreas(req, 'scanAreas') + const scanAreasObj = Object.fromEntries( + scanAreas.features + .filter((feature) => feature.properties.key) + .map((feature) => [feature.properties.key, feature]), + ) + const names = new Set() + /** @type {Record} */ + const withoutParents = {} + + scanAreas.features.forEach((feature) => { + const { key, manual, name } = feature.properties + + if ( + !key || + !name || + manual || + !feature.geometry?.type?.includes('Polygon') + ) { + return + } + + names.add(key) + if (!withoutParents[name]) { + withoutParents[name] = [] + } + withoutParents[name].push(key) + }) + + return { + names, + scanAreas: { current: scanAreas }, + scanAreasObj, + withoutParents, + } +} + +/** + * @param {import('express').Request} req + * @returns {string} + */ +function getRequestAreaDomain(req) { + const domain = req.headers.host.replaceAll('.', '_') + const location = `areas.scanAreas.${domain}` + return typeof config.has === 'function' && config.has(location) + ? domain + : 'main' +} + +/** + * @param {string[]} perms + * @param {string[]} areaKeys + * @param {{ + * scanAreasObj: Record, + * }} areas + * @param {import('express').Request} req + */ +function pushRequestScopedAreaGrants(perms, areaKeys, areas, req) { + const domain = getRequestAreaDomain(req) + + areaKeys.forEach((key) => { + if (areas.scanAreasObj[key]) { + perms.push(encodeAreaGrant(domain, key)) + } + }) +} + +/** + * Resolves config entries into canonical area keys. + * `parent` rules expand to visible child keys and only fall back to the + * parent's own area key when no visible children are available. + * + * @param {string[]} perms + * @param {string | undefined} target + * @param {{ + * names: Set, + * scanAreasObj: Record, + * withoutParents: Record, + * }} areas + * @param {ReturnType} areaMaps + * @param {boolean} [includeChildren] + * @param {'auto' | 'key' | 'label'} [lookupMode] + */ +function pushAreaKeys( + perms, + target, + areas, + areaMaps, + includeChildren = false, + lookupMode = 'auto', +) { + if (!target) return + + const allowCanonicalLookup = lookupMode !== 'label' + const allowLabelLookup = lookupMode !== 'key' + const targetFeature = allowCanonicalLookup ? areas.scanAreasObj[target] : null + const isCanonicalTarget = targetFeature?.properties?.key === target + + if (isCanonicalTarget) { + if (includeChildren && !targetFeature?.properties?.parent) { + const parentName = targetFeature?.properties?.name + const domain = + areaMaps.keyDomainsMap[target]?.length === 1 + ? areaMaps.keyDomainsMap[target][0] + : undefined + const scopedKey = + parentName && domain ? `${domain}:${parentName}` : undefined + const scopedChildren = scopedKey + ? areaMaps.scopedParentKeyMap[scopedKey] || [] + : [] + + if (scopedChildren.length) { + perms.push(...scopedChildren) + } else { + perms.push(target) + } + } else { + perms.push(target) + } + } + + const nameMatches = allowLabelLookup ? areas.withoutParents[target] || [] : [] + const visibleNameMatches = nameMatches.filter( + (key) => !areas.scanAreasObj[key]?.properties?.hidden, + ) + const directNameMatches = includeChildren ? visibleNameMatches : nameMatches + if ( + !isCanonicalTarget && + directNameMatches.length && + (!includeChildren || !areaMaps.parentDomainsMap[target]?.length) + ) { + perms.push(...directNameMatches) + } + + if ( + allowLabelLookup && + !includeChildren && + !directNameMatches.length && + areaMaps.parentDomainsMap[target]?.length === 1 + ) { + const scopedKey = `${areaMaps.parentDomainsMap[target][0]}:${target}` + if (!areaMaps.scopedParentAreaKeyMap[scopedKey]) { + perms.push(...(areaMaps.scopedParentKeyMap[scopedKey] || [])) + } + } + + // Bare parent names are ambiguous across multi-domain configs, so only + // expand children when the parent label resolves to exactly one domain. + if ( + allowLabelLookup && + includeChildren && + areaMaps.parentDomainsMap[target]?.length === 1 + ) { + const scopedKey = `${areaMaps.parentDomainsMap[target][0]}:${target}` + const scopedChildren = areaMaps.scopedParentKeyMap[scopedKey] || [] + if (scopedChildren.length) { + perms.push(...scopedChildren) + } else if (areaMaps.scopedParentAreaKeyMap[scopedKey]) { + perms.push(areaMaps.scopedParentAreaKeyMap[scopedKey]) + } + } +} + +/** + * @param {string | undefined} target + * @param {{ + * names: Set, + * scanAreasObj: Record, + * withoutParents: Record, + * }} areas + * @param {ReturnType} areaMaps + * @param {boolean} [includeChildren] + * @returns {'key' | 'label' | 'none'} + */ +function getAreaLookupMode(target, areas, areaMaps, includeChildren = false) { + if (!target) { + return 'none' + } + + if (areas.scanAreasObj[target]) { + return 'key' + } + + const resolved = [] + pushAreaKeys(resolved, target, areas, areaMaps, includeChildren, 'label') + return resolved.length ? 'label' : 'none' +} + +/** + * Preserve legacy grouped parent names that still live under the `areas` + * config key, while keeping the direct parent polygon available when the + * target is also a canonical area key. + * + * @param {string | undefined} target + * @param {{ + * names: Set, + * scanAreasObj: Record, + * withoutParents: Record, + * }} areas + * @param {ReturnType} areaMaps + * @returns {boolean} + */ +function shouldUseParentGrantForLegacyArea(target, areas, areaMaps) { + if (!target || !areas.scanAreasObj[target]) { + return false + } + + const labelResolved = [] + pushAreaKeys(labelResolved, target, areas, areaMaps, true, 'label') + const uniqueLabelResolved = [...new Set(labelResolved)] + + return ( + !!uniqueLabelResolved.length && + (uniqueLabelResolved.length !== 1 || uniqueLabelResolved[0] !== target) + ) +} + +/** + * @param {string[]} roles + * @param {import('express').Request} [req] + * @param {boolean} [serializeScopedGrants] + * @returns {{ areaRestrictions: string[], hasUnrestrictedGrant: boolean }} + */ +function resolveAreaPerms(roles, req, serializeScopedGrants = false) { const areaRestrictions = config.getSafe('authentication.areaRestrictions') - const areas = config.getSafe('areas') + const globalAreas = getRestrictionAreas() + const globalAreaMaps = getAreaMaps(globalAreas.scanAreas) + const requestAreas = req ? getRestrictionAreas(req) : globalAreas + const requestAreaMaps = req + ? getAreaMaps(requestAreas.scanAreas) + : globalAreaMaps const perms = [] + let matchedRestrictedRule = false for (let i = 0; i < roles.length; i += 1) { for (let j = 0; j < areaRestrictions.length; j += 1) { - if (areaRestrictions[j].roles.includes(roles[i])) { - if (areaRestrictions[j].areas.length) { - for (let k = 0; k < areaRestrictions[j].areas.length; k += 1) { - if (areas.names.has(areaRestrictions[j].areas[k])) { - perms.push(areaRestrictions[j].areas[k]) - } else if (areas.withoutParents[areaRestrictions[j].areas[k]]) { - perms.push(...areas.withoutParents[areaRestrictions[j].areas[k]]) - } + if (!areaRestrictions[j].roles.includes(roles[i])) continue + + const hasAreas = Array.isArray(areaRestrictions[j].areas) + ? areaRestrictions[j].areas.length > 0 + : false + const hasParents = Array.isArray(areaRestrictions[j].parent) + ? areaRestrictions[j].parent.length > 0 + : false + + // No areas/parents means unrestricted access + if (!hasAreas && !hasParents) { + return { + areaRestrictions: [UNRESTRICTED_ACCESS_SENTINEL], + hasUnrestrictedGrant: true, + } + } + + matchedRestrictedRule = true + + if (hasAreas) { + for (let k = 0; k < areaRestrictions[j].areas.length; k += 1) { + const areaTarget = areaRestrictions[j].areas[k] + const areaLookupMode = + !!req && serializeScopedGrants + ? getAreaLookupMode( + areaTarget, + requestAreas, + requestAreaMaps, + false, + ) + : undefined + const usesGlobalAreaLookup = + !req || globalAreas.scanAreasObj[areaTarget] + const shouldSerializeScopedAreaGrant = + !!req && + serializeScopedGrants && + (areaLookupMode === 'label' || + !usesGlobalAreaLookup || + globalAreaMaps.keyDomainsMap[areaTarget]?.length > 1) + const shouldSerializeLegacyParentGrant = + !!req && + serializeScopedGrants && + shouldUseParentGrantForLegacyArea( + areaTarget, + requestAreas, + requestAreaMaps, + ) + + if (shouldSerializeLegacyParentGrant) { + perms.push( + encodeParentAreaGrant( + req ? getRequestAreaDomain(req) : '', + areaTarget, + ), + ) + } else if (shouldSerializeScopedAreaGrant) { + perms.push( + encodeAreaGrant(req ? getRequestAreaDomain(req) : '', areaTarget), + ) + } else { + pushAreaKeys( + perms, + areaTarget, + usesGlobalAreaLookup ? globalAreas : requestAreas, + usesGlobalAreaLookup ? globalAreaMaps : requestAreaMaps, + false, + ) + } + } + } + + if (hasParents) { + for (let k = 0; k < areaRestrictions[j].parent.length; k += 1) { + if (serializeScopedGrants) { + perms.push( + encodeParentAreaGrant( + req ? getRequestAreaDomain(req) : '', + areaRestrictions[j].parent[k], + ), + ) + } else if (req) { + pushAreaKeys( + perms, + areaRestrictions[j].parent[k], + requestAreas, + requestAreaMaps, + true, + ) + } else { + perms.push(encodeParentAreaGrant(areaRestrictions[j].parent[k])) } - } else { - return [] } } } } - return [...new Set(perms)] + + const uniquePerms = [...new Set(perms)] + return { + areaRestrictions: + matchedRestrictedRule && !uniquePerms.length + ? [NO_ACCESS_SENTINEL] + : uniquePerms, + hasUnrestrictedGrant: false, + } } -module.exports = { areaPerms } +/** + * @param {string[]} [areaRestrictions] + * @param {import('express').Request} [req] + * @returns {string[]} + */ +function normalizeAreaRestrictions(areaRestrictions, req) { + const safeAreaRestrictions = areaRestrictions || [] + const authentication = config.getSafe('authentication') + const hasImplicitUnrestrictedGrant = + !!req && + !safeAreaRestrictions.length && + (!authentication.areaRestrictions.length || + !authentication.strictAreaRestrictions) + + if ( + hasUnrestrictedAreaGrant(safeAreaRestrictions) || + hasImplicitUnrestrictedGrant + ) { + return req + ? [ + UNRESTRICTED_ACCESS_SENTINEL, + encodeAreaScope(getRequestAreaDomain(req)), + ] + : [UNRESTRICTED_ACCESS_SENTINEL] + } + + const globalAreas = getRestrictionAreas() + const globalAreaMaps = getAreaMaps(globalAreas.scanAreas) + const requestAreas = req ? getRestrictionAreas(req) : globalAreas + const requestAreaMaps = req + ? getAreaMaps(requestAreas.scanAreas) + : globalAreaMaps + const normalized = [] + + safeAreaRestrictions.forEach((area) => { + if (isAreaGrant(area)) { + if (req) { + const areaGrant = decodeAreaGrant(area) + if (areaGrant.domain && areaGrant.domain !== getRequestAreaDomain(req)) + return + + const resolvedAreaKeys = [] + + normalized.push(area) + pushAreaKeys( + resolvedAreaKeys, + areaGrant.area, + requestAreas, + requestAreaMaps, + false, + ) + normalized.push(...resolvedAreaKeys) + pushRequestScopedAreaGrants( + normalized, + resolvedAreaKeys, + requestAreas, + req, + ) + } else { + normalized.push(area) + } + return + } + + if (isParentAreaGrant(area)) { + if (req) { + const parentGrant = decodeParentAreaGrant(area) + if ( + parentGrant.domain && + parentGrant.domain !== getRequestAreaDomain(req) + ) + return + + normalized.push(area) + const resolvedAreaKeys = [] + pushAreaKeys( + resolvedAreaKeys, + parentGrant.area, + requestAreas, + requestAreaMaps, + true, + ) + normalized.push(...resolvedAreaKeys) + pushRequestScopedAreaGrants( + normalized, + resolvedAreaKeys, + requestAreas, + req, + ) + } else { + normalized.push(area) + } + return + } + + const usesGlobalAreaLookup = !req + const resolvedAreaKeys = [] + + pushAreaKeys( + resolvedAreaKeys, + area, + usesGlobalAreaLookup ? globalAreas : requestAreas, + usesGlobalAreaLookup ? globalAreaMaps : requestAreaMaps, + false, + 'key', + ) + normalized.push(...resolvedAreaKeys) + if (req) { + pushRequestScopedAreaGrants( + normalized, + resolvedAreaKeys, + requestAreas, + req, + ) + } + }) + + const uniquePerms = [...new Set(normalized)] + return uniquePerms.length + ? uniquePerms + : safeAreaRestrictions.length + ? [NO_ACCESS_SENTINEL] + : uniquePerms +} + +/** + * @param {string[]} roles + * @param {import('express').Request} [req] + * @param {boolean} [serializeScopedGrants] + * @returns {string[]} + */ +function areaPerms(roles, req, serializeScopedGrants = false) { + return resolveAreaPerms(roles, req, serializeScopedGrants).areaRestrictions +} + +module.exports = { + areaPerms, + decodeAreaGrant, + decodeAreaScope, + decodeParentAreaGrant, + getPublicAreaRestrictions, + hasUnrestrictedAreaGrant, + isAreaGrant, + isAreaScope, + isParentAreaGrant, + NO_ACCESS_SENTINEL, + UNRESTRICTED_ACCESS_SENTINEL, + normalizeAreaRestrictions, + resolveAreaPerms, +} diff --git a/server/src/utils/consolidateAreas.js b/server/src/utils/consolidateAreas.js index 3eeae1775..f44fa6908 100644 --- a/server/src/utils/consolidateAreas.js +++ b/server/src/utils/consolidateAreas.js @@ -1,24 +1,206 @@ // @ts-check const config = require('@rm/config') +const { + decodeAreaGrant, + decodeAreaScope, + decodeParentAreaGrant, + isAreaGrant, + isAreaScope, + isParentAreaGrant, + UNRESTRICTED_ACCESS_SENTINEL, +} = require('./areaPerms') /** * Consolidate area restrictions and user set areas, accounts for parents * @param {string[]} areaRestrictions * @param {string[]} onlyAreas - * @returns {Set} + * @returns {Set} */ function consolidateAreas(areaRestrictions = [], onlyAreas = []) { const areas = config.getSafe('areas') - const validAreaRestrictions = areaRestrictions.filter((a) => - areas.names.has(a), + const featureEntriesByKey = Object.entries(areas.scanAreas).reduce( + (acc, [domain, featureCollection]) => { + featureCollection.features.forEach((feature) => { + if ( + !feature.properties.key || + feature.properties.manual || + !feature.geometry?.type?.includes('Polygon') + ) { + return + } + if (!acc[feature.properties.key]) { + acc[feature.properties.key] = [] + } + acc[feature.properties.key].push({ domain, feature }) + }) + return acc + }, + /** @type {Record} */ ({}), ) - const validUserAreas = onlyAreas.filter((a) => areas.names.has(a)) + const childFeaturesByKey = Object.entries(areas.scanAreas).reduce( + (acc, [domain, featureCollection]) => { + const parentKeysByName = Object.fromEntries( + featureCollection.features + .filter( + (feature) => + !feature.properties.parent && + feature.properties.name && + feature.properties.key, + ) + .map((feature) => [feature.properties.name, feature.properties.key]), + ) - const cleanedValidUserAreas = validUserAreas.filter((area) => - areaRestrictions.length - ? areaRestrictions.includes(area) || - areaRestrictions.includes(areas.scanAreasObj[area].properties.parent) - : true, + featureCollection.features.forEach((feature) => { + if ( + feature.properties.key && + feature.properties.parent && + !feature.properties.manual && + feature.geometry?.type?.includes('Polygon') + ) { + if (!acc[feature.properties.key]) { + acc[feature.properties.key] = [] + } + acc[feature.properties.key].push({ + domain, + feature, + parentKey: parentKeysByName[feature.properties.parent] || '', + parentName: feature.properties.parent, + }) + } + }) + return acc + }, + /** @type {Record} */ ({}), + ) + const plainAreaRestrictions = areaRestrictions.filter( + (area) => + area !== UNRESTRICTED_ACCESS_SENTINEL && + !isAreaGrant(area) && + !isAreaScope(area) && + !isParentAreaGrant(area), + ) + const requestScopedDomains = areaRestrictions.reduce((acc, area) => { + if (!isAreaScope(area)) { + return acc + } + acc.add(decodeAreaScope(area).domain) + return acc + }, /** @type {Set} */ (new Set())) + const scopedAreaDomains = areaRestrictions.reduce((acc, area) => { + if (!isAreaGrant(area)) { + return acc + } + const areaGrant = decodeAreaGrant(area) + if (!areaGrant.domain) { + return acc + } + if (!acc[areaGrant.area]) { + acc[areaGrant.area] = new Set() + } + acc[areaGrant.area].add(areaGrant.domain) + return acc + }, /** @type {Record>} */ ({})) + const scopedParentDomains = areaRestrictions.reduce((acc, area) => { + if (!isParentAreaGrant(area)) { + return acc + } + const parentGrant = decodeParentAreaGrant(area) + if (!parentGrant.domain) { + return acc + } + if (!acc[parentGrant.area]) { + acc[parentGrant.area] = new Set() + } + acc[parentGrant.area].add(parentGrant.domain) + return acc + }, /** @type {Record>} */ ({})) + const getScopedDomains = (area, includeRequestScope = false) => { + const scopedDomains = new Set([ + ...(scopedAreaDomains[area] || []), + ...(scopedParentDomains[area] || []), + ]) + + if (scopedDomains.size) { + return scopedDomains + } + + ;(childFeaturesByKey[area] || []).forEach( + ({ domain, parentKey, parentName }) => { + const matchingParentDomains = new Set([ + ...(scopedParentDomains[parentKey] || []), + ...(scopedParentDomains[parentName] || []), + ]) + if (matchingParentDomains.has(domain)) { + scopedDomains.add(domain) + } + }, + ) + if (!scopedDomains.size && includeRequestScope) { + return requestScopedDomains + } + return scopedDomains + } + const getDirectFeatures = (area, includeRequestScope = false) => { + const featureEntries = featureEntriesByKey[area] || [] + if (!featureEntries.length) { + return [] + } + + const scopedDomains = getScopedDomains(area, includeRequestScope) + if (scopedDomains.size) { + return featureEntries + .filter(({ domain }) => scopedDomains.has(domain)) + .map(({ feature }) => feature) + } + + const distinctDomains = new Set(featureEntries.map(({ domain }) => domain)) + return distinctDomains.size === 1 + ? featureEntries.map(({ feature }) => feature) + : [] + } + const validAreaRestrictions = plainAreaRestrictions.flatMap(getDirectFeatures) + const validUserAreas = onlyAreas.filter( + (area) => featureEntriesByKey[area]?.length, + ) + const hasExplicitRestrictions = + !!plainAreaRestrictions.length || + !!Object.keys(scopedAreaDomains).length || + !!Object.keys(scopedParentDomains).length + const allowedDirectAreas = new Set(plainAreaRestrictions) + + const cleanedValidUserAreas = validUserAreas.flatMap((area) => + hasExplicitRestrictions + ? allowedDirectAreas.has(area) + ? getDirectFeatures(area) + : (() => { + const matchingChildren = (childFeaturesByKey[area] || []).filter( + ({ domain, parentKey, parentName }) => { + const scopedDomains = new Set([ + ...(scopedAreaDomains[parentKey] || []), + ...(scopedAreaDomains[parentName] || []), + ...(scopedParentDomains[parentKey] || []), + ...(scopedParentDomains[parentName] || []), + ]) + return scopedDomains.size + ? scopedDomains.has(domain) + : (!!parentKey && + plainAreaRestrictions.includes(parentKey)) || + plainAreaRestrictions.includes(parentName) + }, + ) + const distinctDomains = new Set( + matchingChildren.map(({ domain }) => domain), + ) + return distinctDomains.size === 1 + ? matchingChildren.map(({ feature }) => feature) + : [] + })() + : getDirectFeatures(area, true), ) return new Set( cleanedValidUserAreas.length diff --git a/server/src/utils/filterRTree.js b/server/src/utils/filterRTree.js index cbca8cdea..9e2c02ca0 100644 --- a/server/src/utils/filterRTree.js +++ b/server/src/utils/filterRTree.js @@ -4,6 +4,7 @@ const { point } = require('@turf/helpers') const config = require('@rm/config') const { consolidateAreas } = require('./consolidateAreas') +const { hasUnrestrictedAreaGrant } = require('./areaPerms') /** * Filters via RTree in place of MySQL query when using in memory data @@ -14,11 +15,13 @@ const { consolidateAreas } = require('./consolidateAreas') * @returns {boolean} */ function filterRTree(item, areaRestrictions = [], onlyAreas = []) { + const unrestrictedAreaGrant = hasUnrestrictedAreaGrant(areaRestrictions) + if (unrestrictedAreaGrant && !onlyAreas.length) return true if (!areaRestrictions.length && !onlyAreas.length) return true const consolidatedAreas = consolidateAreas(areaRestrictions, onlyAreas) - if (!consolidatedAreas.size) return true + if (!consolidatedAreas.size) return false /** @type {import("@rm/types").RMGeoJSON['features']} */ const foundFeatures = config @@ -29,7 +32,7 @@ function filterRTree(item, areaRestrictions = [], onlyAreas = []) { w: 0, h: 0, }) - .filter((feature) => consolidatedAreas.has(feature.properties.key)) + .filter((feature) => consolidatedAreas.has(feature)) const foundInRtree = foundFeatures.length && diff --git a/server/src/utils/getAccessibleScanAreasMenu.js b/server/src/utils/getAccessibleScanAreasMenu.js new file mode 100644 index 000000000..ca2ad6142 --- /dev/null +++ b/server/src/utils/getAccessibleScanAreasMenu.js @@ -0,0 +1,58 @@ +// @ts-check +const config = require('@rm/config') + +const { hasUnrestrictedAreaGrant } = require('./areaPerms') + +/** + * @param {import('express').Request} req + * @param {Partial} perms + * @returns {import('@rm/types').Config['areas']['scanAreasMenu'][string]} + */ +function getAccessibleScanAreasMenu(req, perms) { + if (!perms?.scanAreas) { + return [] + } + + const scanAreas = config.getAreas(req, 'scanAreasMenu') + const unrestrictedAreaGrant = hasUnrestrictedAreaGrant(perms.areaRestrictions) + const baseMenu = scanAreas.map((parent) => ({ + ...parent, + details: + parent.details && !parent.details.properties.hidden + ? parent.details + : null, + })) + + if (perms.areaRestrictions?.length && !unrestrictedAreaGrant) { + const canAccessArea = (properties) => + perms.areaRestrictions.includes(properties.key) + + return baseMenu + .map((parent) => { + const canAccessParent = + !!parent.details && canAccessArea(parent.details.properties) + const children = parent.children.filter( + (child) => + canAccessArea(child.properties) || + (canAccessParent && child.properties.manual), + ) + const hasSelectableChild = children.some( + (child) => !child.properties.manual, + ) + + return { + ...parent, + details: + parent.details && (canAccessParent || hasSelectableChild) + ? parent.details + : null, + children, + } + }) + .filter((parent) => parent.details || parent.children.length) + } + + return baseMenu.filter((parent) => parent.details || parent.children.length) +} + +module.exports = { getAccessibleScanAreasMenu } diff --git a/server/src/utils/getAreaSql.js b/server/src/utils/getAreaSql.js index 7174cff15..ba692db1c 100644 --- a/server/src/utils/getAreaSql.js +++ b/server/src/utils/getAreaSql.js @@ -1,6 +1,7 @@ // @ts-check const config = require('@rm/config') const { consolidateAreas } = require('./consolidateAreas') +const { hasUnrestrictedAreaGrant } = require('./areaPerms') /** * @@ -19,14 +20,16 @@ function getAreaSql( category = '', ) { const authentication = config.getSafe('authentication') - const areas = config.getSafe('areas') + const unrestrictedAreaGrant = hasUnrestrictedAreaGrant(areaRestrictions) if ( authentication.strictAreaRestrictions && authentication.areaRestrictions.length && - !areaRestrictions.length + !areaRestrictions.length && + !unrestrictedAreaGrant ) return false + if (unrestrictedAreaGrant && !onlyAreas?.length) return true if (!areaRestrictions?.length && !onlyAreas?.length) return true const consolidatedAreas = consolidateAreas(areaRestrictions, onlyAreas) @@ -59,10 +62,10 @@ function getAreaSql( query.andWhere((restrictions) => { consolidatedAreas.forEach((area) => { - if (areas.polygons[area]) { + if (area.geometry?.type?.includes('Polygon')) { restrictions.orWhereRaw( `ST_CONTAINS(ST_GeomFromGeoJSON('${JSON.stringify( - areas.polygons[area], + area.geometry, )}', 2, 0), POINT(${columns[1]}, ${columns[0]}))`, ) } diff --git a/server/src/utils/getServerSettings.js b/server/src/utils/getServerSettings.js index 790845db0..4a0b9a503 100644 --- a/server/src/utils/getServerSettings.js +++ b/server/src/utils/getServerSettings.js @@ -4,6 +4,10 @@ const config = require('@rm/config') const { clientOptions } = require('../ui/clientOptions') const { advMenus } = require('../ui/advMenus') const { drawer } = require('../ui/drawer') +const { + getPublicAreaRestrictions, + normalizeAreaRestrictions, +} = require('./areaPerms') /** * @@ -16,8 +20,29 @@ function getServerSettings(req) { loggedIn: !!req.user, cooldown: req.session?.cooldown || 0, }) + const normalizedPerms = user.perms + ? { + ...user.perms, + areaRestrictions: normalizeAreaRestrictions( + user.perms.areaRestrictions || [], + req, + ), + } + : user.perms - const { clientValues, clientMenus } = clientOptions(user.perms) + const safeUser = { + ...user, + perms: normalizedPerms + ? { + ...normalizedPerms, + areaRestrictions: getPublicAreaRestrictions( + normalizedPerms.areaRestrictions, + ), + } + : normalizedPerms, + } + + const { clientValues, clientMenus } = clientOptions(safeUser.perms) const mapConfig = config.getMapConfig(req) const api = config.getSafe('api') @@ -29,7 +54,7 @@ function getServerSettings(req) { polling: api.polling, gymValidDataLimit: Date.now() / 1000 - api.gymValidDataLimit * 86400, }, - user, + user: safeUser, authReferences: { areaRestrictions: authentication.areaRestrictions.length, webhooks: config.getSafe('webhooks').filter((w) => w.enabled).length, @@ -61,10 +86,10 @@ function getServerSettings(req) { }, tileServers: config.getSafe('tileServers'), navigation: config.getSafe('navigation'), - menus: advMenus(user.perms), + menus: advMenus(safeUser.perms), userSettings: clientValues, clientMenus, - ui: drawer(req, user.perms), + ui: drawer(req, safeUser.perms), } return serverSettings diff --git a/server/src/utils/mergePerms.js b/server/src/utils/mergePerms.js index 3e13abf31..2c83f2ae0 100644 --- a/server/src/utils/mergePerms.js +++ b/server/src/utils/mergePerms.js @@ -1,5 +1,4 @@ // @ts-check - /** * * @param {import("@rm/types").Permissions} existingPerms diff --git a/src/components/auth/Local.jsx b/src/components/auth/Local.jsx index 85e2b520d..ac7247c60 100644 --- a/src/components/auth/Local.jsx +++ b/src/components/auth/Local.jsx @@ -51,6 +51,8 @@ export function LocalLogin({ href, sx, style }) { } else if ('url' in resp && resp.url.includes('invalid_credentials')) { setError(t('invalid_credentials')) setSubmitted(false) + } else if ('url' in resp && resp.url.includes('/blocked/')) { + window.location.replace(resp.url) } else { window.location.replace('/') } diff --git a/src/features/drawer/areas/AreaTable.jsx b/src/features/drawer/areas/AreaTable.jsx index ffdfcf83d..e61f57dea 100644 --- a/src/features/drawer/areas/AreaTable.jsx +++ b/src/features/drawer/areas/AreaTable.jsx @@ -30,6 +30,9 @@ export function ScanAreasTable() { const rawSearch = useStorage((s) => s.filters.scanAreas?.filter?.search || '') const search = React.useMemo(() => rawSearch.toLowerCase(), [rawSearch]) const trimmedSearch = React.useMemo(() => rawSearch.trim(), [rawSearch]) + const accessibleAreaKeys = useMemory( + (s) => s.auth.perms.areaRestrictions || [], + ) const { misc, general } = useMemory.getState().config const jumpZoom = general?.scanAreasZoom || general?.startZoom || 12 /** @type {[JumpResult[], React.Dispatch>]} */ @@ -49,13 +52,22 @@ export function ScanAreasTable() { /** @type {string[]} */ const allAreas = React.useMemo( - () => - data?.scanAreasMenu.flatMap((parent) => - parent.children - .filter((child) => !child.properties.manual) - .map((child) => child.properties.key), - ) || [], - [data], + () => [ + ...new Set( + data?.scanAreasMenu.flatMap((parent) => [ + ...(parent.details?.properties?.key && + !parent.details.properties.manual && + (!accessibleAreaKeys.length || + accessibleAreaKeys.includes(parent.details.properties.key)) + ? [parent.details.properties.key] + : []), + ...parent.children + .filter((child) => !child.properties.manual) + .map((child) => child.properties.key), + ]) || [], + ), + ], + [accessibleAreaKeys, data], ) const allRows = React.useMemo( @@ -63,6 +75,19 @@ export function ScanAreasTable() { (data?.scanAreasMenu || []) .map((area) => ({ ...area, + allChildren: area.children, + groupKey: + area.details?.properties?.manual || + !area.details?.properties?.key || + (accessibleAreaKeys.length && + !accessibleAreaKeys.includes(area.details.properties.key)) + ? undefined + : area.details.properties.key, + details: + search === '' || + area.details?.properties?.key?.toLowerCase()?.includes(search) + ? area.details + : null, children: area.children.filter( (feature) => search === '' || @@ -90,7 +115,11 @@ export function ScanAreasTable() { ) const totalMatches = React.useMemo( - () => allRows.reduce((sum, area) => sum + area.children.length, 0), + () => + allRows.reduce( + (sum, area) => sum + area.children.length + (area.details ? 1 : 0), + 0, + ), [allRows], ) @@ -109,6 +138,7 @@ export function ScanAreasTable() { setJumpResults([]) setJumpLoading(true) + let active = true const controller = new AbortController() const timer = window.setTimeout(() => { fetch( @@ -120,6 +150,7 @@ export function ScanAreasTable() { return res.json() }) .then((json) => { + if (!active) return if (!Array.isArray(json)) { setJumpResults([]) return @@ -141,16 +172,19 @@ export function ScanAreasTable() { setJumpError(false) }) .catch((err) => { - if (err.name === 'AbortError') return + if (!active || err.name === 'AbortError') return setJumpResults([]) setJumpError(true) }) .finally(() => { - setJumpLoading(false) + if (active) { + setJumpLoading(false) + } }) }, 400) return () => { + active = false clearTimeout(timer) controller.abort() } @@ -176,44 +210,52 @@ export function ScanAreasTable() { })} > - {allRows.map(({ name, details, children, rows }) => { - if (!children.length) return null - return ( - - {name && ( - - - - )} - - - {rows.map((row, i) => ( - - {row.map((feature, j) => ( - + {allRows.map( + ({ name, details, children, rows, allChildren, groupKey }) => { + if (!children.length && !details) return null + return ( + + {name && ( + + + + )} + {!!rows.length && ( + + + {rows.map((row, i) => ( + + {row.map((feature, j) => ( + + ))} + ))} - - ))} - - - - ) - })} + + + )} + + ) + }, + )} {showJumpResults && ( diff --git a/src/features/drawer/areas/Child.jsx b/src/features/drawer/areas/Child.jsx index a22f72aa8..16d728536 100644 --- a/src/features/drawer/areas/Child.jsx +++ b/src/features/drawer/areas/Child.jsx @@ -18,6 +18,8 @@ import { useMemory } from '@store/useMemory' * feature?: Pick * allAreas?: string[] * childAreas?: Pick[] + * allChildAreas?: Pick[] + * groupKey?: string * borderRight?: boolean * colSpan?: number * }} props @@ -26,6 +28,8 @@ export function AreaChild({ name, feature, childAreas, + allChildAreas, + groupKey, allAreas, borderRight, colSpan = 1, @@ -33,6 +37,9 @@ export function AreaChild({ const scanAreas = useStorage((s) => s.filters?.scanAreas?.filter?.areas) const zoom = useMemory((s) => s.config.general.scanAreasZoom) const expandAllScanAreas = useMemory((s) => s.config.misc.expandAllScanAreas) + const accessibleAreaKeys = useMemory( + (s) => s.auth.perms.areaRestrictions || [], + ) const map = useMap() const { setAreas } = useStorage.getState() @@ -40,23 +47,56 @@ export function AreaChild({ if (!scanAreas) return null + const groupedChildren = name + ? allChildAreas || childAreas || [] + : childAreas || [] + const groupedAreaKeys = groupedChildren + .filter((child) => !child.properties.manual) + .map((child) => child.properties.key) + const parentAreaKeys = + name && + feature?.properties?.key && + !feature.properties.manual && + (!accessibleAreaKeys.length || + accessibleAreaKeys.includes(feature.properties.key)) + ? [feature.properties.key] + : [] + const selectableAreaKeys = name + ? [...new Set([...groupedAreaKeys, ...parentAreaKeys])] + : [] + const removableAreaKeys = + name && feature?.properties?.key && !feature.properties.manual + ? [...new Set([...selectableAreaKeys, feature.properties.key])] + : selectableAreaKeys const hasAll = - childAreas && - childAreas.every( - (c) => c.properties.manual || scanAreas.includes(c.properties.key), - ) + name && selectableAreaKeys.length + ? selectableAreaKeys.every((key) => scanAreas.includes(key)) + : false const hasSome = - childAreas && childAreas.some((c) => scanAreas.includes(c.properties.key)) - const hasManual = - feature?.properties?.manual || childAreas.every((c) => c.properties.manual) + name && removableAreaKeys.length + ? removableAreaKeys.some((key) => scanAreas.includes(key)) + : false + const allChildrenManual = + name && + !!groupedChildren.length && + groupedChildren.every((child) => child.properties.manual) + const hasManual = name + ? !selectableAreaKeys.length && + (feature?.properties?.manual || allChildrenManual) + : feature?.properties?.manual const color = - hasManual || (name ? !childAreas.length : !feature.properties.name) + hasManual || + (name ? !selectableAreaKeys.length : !feature?.properties?.name) ? 'transparent' : 'none' + const coveredByGroup = + !name && !feature?.properties?.manual && groupKey + ? scanAreas.includes(groupKey) + : false const nameProp = name || feature?.properties?.formattedName || feature?.properties?.name - const hasExpand = name && !expandAllScanAreas + const hasExpand = name && !expandAllScanAreas && !!childAreas?.length return ( e.stopPropagation()} - onChange={() => - setAreas( - name - ? childAreas.map((c) => c.properties.key) - : feature.properties.key, - allAreas, - name ? hasSome : false, - ) + checked={ + name + ? hasAll + : coveredByGroup || scanAreas.includes(feature.properties.key) } + onClick={(e) => e.stopPropagation()} + onChange={() => { + let areaKeys = name + ? hasSome + ? removableAreaKeys + : selectableAreaKeys + : feature.properties.key + + if (!name && coveredByGroup) { + const siblingAreaKeys = (allChildAreas || childAreas || []) + .filter( + (child) => + !child.properties.manual && + child.properties.key !== feature.properties.key, + ) + .map((child) => child.properties.key) + areaKeys = [ + groupKey, + ...(scanAreas.includes(feature.properties.key) + ? [feature.properties.key] + : []), + ...siblingAreaKeys.filter((key) => !scanAreas.includes(key)), + ] + } + + setAreas(areaKeys, allAreas, name ? hasSome : false) + }} sx={{ p: 1, color, @@ -123,7 +184,7 @@ export function AreaChild({ }, }} disabled={ - (name ? !childAreas.length : !feature.properties.name) || + (name ? !selectableAreaKeys.length : !feature.properties.name) || hasManual } /> diff --git a/src/features/scanArea/ScanAreaTile.jsx b/src/features/scanArea/ScanAreaTile.jsx index 1cbfcb03d..ba3e489ea 100644 --- a/src/features/scanArea/ScanAreaTile.jsx +++ b/src/features/scanArea/ScanAreaTile.jsx @@ -5,8 +5,10 @@ import { GeoJSON } from 'react-leaflet' import { Polygon } from 'leaflet' import { useWebhookStore, handleClick } from '@store/useWebhookStore' +import { useMemory } from '@store/useMemory' import { useStorage } from '@store/useStorage' import { getProperName } from '@utils/strings' +import { getAreaKeys, getValidAreaKeys } from './utils' /** * @@ -15,25 +17,47 @@ import { getProperName } from '@utils/strings' */ function ScanArea(featureCollection) { const search = useStorage((s) => s.filters.scanAreas?.filter?.search) + const selectedAreas = useStorage( + (s) => s.filters.scanAreas?.filter?.areas || [], + ) const tapToToggle = useStorage((s) => s.userSettings.scanAreas.tapToToggle) const alwaysShowLabels = useStorage( (s) => s.userSettings.scanAreas.alwaysShowLabels, ) + const accessibleAreaKeys = useMemory( + (s) => s.auth.perms.areaRestrictions || [], + ) const webhook = useWebhookStore((s) => !!s.mode) + const selectionKey = React.useMemo( + () => [...selectedAreas].sort().join(','), + [selectedAreas], + ) + const orderedFeatureCollection = React.useMemo( + () => ({ + ...featureCollection, + // Render grouped parents underneath children so child taps stay reachable. + features: [...featureCollection.features].sort( + (a, b) => Number(!!a.properties.parent) - Number(!!b.properties.parent), + ), + }), + [featureCollection], + ) return ( webhook || search === '' || - f.properties.key.toLowerCase().includes(search.toLowerCase()) + `${f.properties.key || f.properties.name || ''}` + .toLowerCase() + .includes(search.toLowerCase()) } eventHandlers={{ click: ({ propagatedFrom: layer }) => { if (!layer.feature) return - const { name, key, manual = false } = layer.feature.properties + const { name, manual = false } = layer.feature.properties if (webhook && name && handleClick) { handleClick(name)().then((newAreas) => { layer.setStyle({ @@ -45,21 +69,74 @@ function ScanArea(featureCollection) { }) }) } else if (!manual && tapToToggle) { - const { filters, setAreas } = useStorage.getState() - const includes = filters?.scanAreas?.filter?.areas?.includes(key) - layer.setStyle({ fillOpacity: includes ? 0.2 : 0.8 }) - setAreas( - key, - featureCollection.features - .filter((f) => !f.properties.manual) - .map((f) => f.properties.key), + const areaKeys = getAreaKeys( + featureCollection.features, + layer.feature, + accessibleAreaKeys, + ) + const validAreaKeys = getValidAreaKeys( + featureCollection.features, + accessibleAreaKeys, ) + const { setAreas } = useStorage.getState() + const hasAll = areaKeys.every((area) => + selectedAreas.includes(area), + ) + const legacyGroupKey = layer.feature.properties.parent + ? featureCollection.features.find( + (feature) => + !feature.properties.manual && + !feature.properties.parent && + feature.properties.name === + layer.feature.properties.parent && + feature.properties.key, + )?.properties.key + : undefined + let nextAreaKeys = areaKeys + let unselectAll = hasAll + + if (legacyGroupKey && selectedAreas.includes(legacyGroupKey)) { + const siblingAreaKeys = featureCollection.features + .filter( + (feature) => + !feature.properties.manual && + feature.properties.parent === + layer.feature.properties.parent && + feature.properties.key !== layer.feature.properties.key, + ) + .map((feature) => feature.properties.key) + nextAreaKeys = [ + legacyGroupKey, + ...(selectedAreas.includes(layer.feature.properties.key) + ? [layer.feature.properties.key] + : []), + ...siblingAreaKeys.filter( + (key) => !selectedAreas.includes(key), + ), + ] + unselectAll = false + } else if (areaKeys.length > 1 && !hasAll) { + nextAreaKeys = areaKeys.filter( + (area) => !selectedAreas.includes(area), + ) + } + + layer.setStyle({ fillOpacity: hasAll ? 0.2 : 0.8 }) + setAreas(nextAreaKeys, validAreaKeys, unselectAll) } }, }} onEachFeature={(feature, layer) => { if (feature.properties?.name) { - const { name, key } = feature.properties + const { name } = feature.properties + const areaKeys = getAreaKeys( + featureCollection.features, + feature, + accessibleAreaKeys, + ) + const isSelected = areaKeys.length + ? areaKeys.every((area) => selectedAreas.includes(area)) + : false const popupContent = getProperName(name) if (layer instanceof Polygon) { layer @@ -81,10 +158,7 @@ function ScanArea(featureCollection) { .human?.area?.some( (area) => area.toLowerCase() === name?.toLowerCase(), ) - : ( - useStorage.getState().filters?.scanAreas?.filter - ?.areas || [] - ).includes(webhook ? name : key) + : isSelected ) ? 0.8 : 0.2, diff --git a/src/features/scanArea/utils.js b/src/features/scanArea/utils.js new file mode 100644 index 000000000..6cea222b2 --- /dev/null +++ b/src/features/scanArea/utils.js @@ -0,0 +1,49 @@ +// @ts-check + +/** + * @param {Pick[]} features + * @param {Pick} feature + * @param {string[]} [accessibleAreaKeys] + * @returns {string[]} + */ +export function getAreaKeys(features, feature, accessibleAreaKeys = []) { + if (!feature?.properties || feature.properties.manual) return [] + + const childKeys = + !feature.properties.parent && feature.properties.name + ? features + .filter( + (child) => + !child.properties.manual && + child.properties.parent === feature.properties.name && + child.properties.key, + ) + .map((child) => child.properties.key) + : [] + + if (feature.properties.key) { + const areaKeys = + !accessibleAreaKeys.length || + accessibleAreaKeys.includes(feature.properties.key) + ? [feature.properties.key] + : [] + return childKeys.length ? [...areaKeys, ...childKeys] : areaKeys + } + + return childKeys +} + +/** + * @param {Pick[]} features + * @param {string[]} [accessibleAreaKeys] + * @returns {string[]} + */ +export function getValidAreaKeys(features, accessibleAreaKeys = []) { + return [ + ...new Set( + features.flatMap((feature) => + getAreaKeys(features, feature, accessibleAreaKeys), + ), + ), + ] +} diff --git a/src/store/useStorage.js b/src/store/useStorage.js index 4ce66c4fb..9e3f15ec5 100644 --- a/src/store/useStorage.js +++ b/src/store/useStorage.js @@ -7,6 +7,8 @@ import { persist, createJSONStorage } from 'zustand/middleware' import { setDeep } from '@utils/setDeep' +const SCAN_AREA_FILTER_RESET_VERSION = 1 + /** * @typedef {{ * darkMode: boolean, @@ -174,6 +176,30 @@ export const useStorage = create( { name: 'local-state', storage: createJSONStorage(() => localStorage), + version: SCAN_AREA_FILTER_RESET_VERSION, + migrate: (persistedState, version) => { + if ( + !persistedState || + version >= SCAN_AREA_FILTER_RESET_VERSION || + !persistedState.filters?.scanAreas?.filter?.areas + ) { + return persistedState + } + + return { + ...persistedState, + filters: { + ...persistedState.filters, + scanAreas: { + ...persistedState.filters.scanAreas, + filter: { + ...persistedState.filters.scanAreas.filter, + areas: [], + }, + }, + }, + } + }, }, ), )