feat: add rule pattern Path Is Exactly + children

pull/8104/head
NGPixel 2 weeks ago
parent 61b17b631f
commit 8c729f8025
No known key found for this signature in database

@ -28,8 +28,8 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
match: { match: {
type: 'string', type: 'string',
description: description:
'How the rule addresses pages. `TAG` (any of them) and `TAGALL` (all of them) match on `tags` and ignore `path`; every other kind compares `path` against the page path and ignores `tags`.', 'How the rule addresses pages. `TAG` (any of them) and `TAGALL` (all of them) match on `tags` and ignore `path`; every other kind compares `path` against the page path and ignores `tags`. `SUBTREE` is `path` and everything filed under it, which is `EXACT` and `START` in one rule without the pages that merely begin with it.',
enum: ['START', 'END', 'REGEX', 'TAG', 'TAGALL', 'EXACT'] enum: ['START', 'END', 'SUBTREE', 'REGEX', 'TAG', 'TAGALL', 'EXACT']
}, },
mode: { mode: {
type: 'string', type: 'string',

@ -67,7 +67,7 @@ export const approvalRules = pgTable(
// -> A rule can be turned off without losing what it says, which is how an administrator suspends // -> A rule can be turned off without losing what it says, which is how an administrator suspends
// suggestions on a section without having to write the rule again afterwards. // suggestions on a section without having to write the rule again afterwards.
isEnabled: boolean().notNull().default(true), isEnabled: boolean().notNull().default(true),
// -> One of START / EXACT / END / REGEX / TAG / TAGALL, the same set group page rules use. A // -> One of START / EXACT / END / REGEX / TAG / TAGALL, matched the way group page rules are. A
// varchar rather than an enum so that adding a mode does not need a migration; the API schema // varchar rather than an enum so that adding a mode does not need a migration; the API schema
// is what rejects an unknown one. // is what rejects an unknown one.
match: varchar({ length: 16 }).notNull().default('START'), match: varchar({ length: 16 }).notNull().default('START'),

@ -28,14 +28,20 @@ import type { GroupRule, GroupRuleMatch, GroupRuleMode } from '../models/groups.
* *
* 1. MATCH TYPE, as bands. From weakest to strongest: * 1. MATCH TYPE, as bands. From weakest to strongest:
* *
* Path Starts With < Path Ends With < Path Matches Regex < * Path Starts With < Path Ends With < Path Is Exactly + Children <
* Has Any Tag < Has All Tags < Path Is Exactly * Path Matches Regex < Has Any Tag < Has All Tags < Path Is Exactly
* *
* The order runs from the vaguest way of naming pages to the most precise: a prefix is a whole * The order runs from the vaguest way of naming pages to the most precise: a prefix is a whole
* branch of the tree, a tag is something somebody put ON the page to say what it is, and an * branch of the tree, a tag is something somebody put ON the page to say what it is, and an
* exact path is one page and nothing else. * exact path is one page and nothing else.
* *
* Three BANDS, because path length below only settles a contest inside one of them: the three * Path Is Exactly + Children is a prefix that stops at a folder boundary: `foo/bar` and
* everything filed under it, where Path Starts With would also take in `foo/barometer`. It
* therefore beats the two loose path kinds — it addresses one branch of the tree rather than
* whatever happens to begin or end that way — while a regex, which can say anything either of
* them can and more, still beats it.
*
* Three BANDS, because path length below only settles a contest inside one of them: the four
* path-shaped kinds, then the two tag kinds, then Path Is Exactly. A tag rule therefore beats a * path-shaped kinds, then the two tag kinds, then Path Is Exactly. A tag rule therefore beats a
* prefix rule however deep that prefix is — `confidential` is denied under `docs` as surely as * prefix rule however deep that prefix is — `confidential` is denied under `docs` as surely as
* anywhere else, and a guests group denying the whole site can still be opened on the pages * anywhere else, and a guests group denying the whole site can still be opened on the pages
@ -52,7 +58,7 @@ import type { GroupRule, GroupRuleMatch, GroupRuleMode } from '../models/groups.
* *
* 3. MATCH TYPE AGAIN, to separate two kinds sharing a band at the same specificity: Has All Tags * 3. MATCH TYPE AGAIN, to separate two kinds sharing a band at the same specificity: Has All Tags
* beats Has Any Tag, since every tag in a list is a stronger claim than any one of them, and a * beats Has Any Tag, since every tag in a list is a stronger claim than any one of them, and a
* regex beats a suffix beats a prefix. * regex beats a branch beats a suffix beats a prefix.
* *
* 4. MODE, when two rules are equally specific and of the same kind: * 4. MODE, when two rules are equally specific and of the same kind:
* *
@ -94,7 +100,15 @@ export interface RulePageRef {
* Match kinds from weakest to strongest. The index IS the priority, so the order of this array is * Match kinds from weakest to strongest. The index IS the priority, so the order of this array is
* the order documented above. * the order documented above.
*/ */
const MATCH_PRIORITY: GroupRuleMatch[] = ['START', 'END', 'REGEX', 'TAG', 'TAGALL', 'EXACT'] const MATCH_PRIORITY: GroupRuleMatch[] = [
'START',
'END',
'SUBTREE',
'REGEX',
'TAG',
'TAGALL',
'EXACT'
]
/** /**
* Which band of the ordering each kind sits in, weakest first — step 1 above. * Which band of the ordering each kind sits in, weakest first — step 1 above.
@ -105,6 +119,7 @@ const MATCH_PRIORITY: GroupRuleMatch[] = ['START', 'END', 'REGEX', 'TAG', 'TAGAL
const MATCH_BAND: Record<GroupRuleMatch, number> = { const MATCH_BAND: Record<GroupRuleMatch, number> = {
START: 0, START: 0,
END: 0, END: 0,
SUBTREE: 0,
REGEX: 0, REGEX: 0,
TAG: 1, TAG: 1,
TAGALL: 1, TAGALL: 1,
@ -201,6 +216,13 @@ export function ruleMatchesPage(rule: GroupRule, page: RulePageRef): boolean {
return pagePath === rulePath return pagePath === rulePath
case 'END': case 'END':
return pagePath.endsWith(rulePath) return pagePath.endsWith(rulePath)
case 'SUBTREE': {
// -> A page and everything filed under it, which `START` cannot say: a prefix of `foo/bar`
// also takes in `foo/barometer`, so saying this with prefixes takes two rules. The empty
// path is the root of the tree and therefore every page, the same as an empty `START`
const branch = rulePath.replace(/\/+$/, '')
return branch.length < 1 || pagePath === branch || pagePath.startsWith(`${branch}/`)
}
case 'REGEX': case 'REGEX':
try { try {
return new RegExp(rulePath).test(pagePath) return new RegExp(rulePath).test(pagePath)

@ -616,6 +616,7 @@
"admin.groups.ruleMatchExact": "Path Is Exactly...", "admin.groups.ruleMatchExact": "Path Is Exactly...",
"admin.groups.ruleMatchRegex": "Path Matches Regex...", "admin.groups.ruleMatchRegex": "Path Matches Regex...",
"admin.groups.ruleMatchStart": "Path Starts With...", "admin.groups.ruleMatchStart": "Path Starts With...",
"admin.groups.ruleMatchSubtree": "Path Is Exactly... + Children",
"admin.groups.ruleMatchTag": "Has Any Tag...", "admin.groups.ruleMatchTag": "Has Any Tag...",
"admin.groups.ruleMatchTagAll": "Has All Tags...", "admin.groups.ruleMatchTagAll": "Has All Tags...",
"admin.groups.rulePath": "Path", "admin.groups.rulePath": "Path",

@ -11,7 +11,7 @@ import type { FastifyRequest } from 'fastify'
export const SYSTEM_PERMISSION = 'manage:system' export const SYSTEM_PERMISSION = 'manage:system'
/** How a rule addresses pages: `TAG` and `TAGALL` read `tags`, everything else reads `path`. */ /** How a rule addresses pages: `TAG` and `TAGALL` read `tags`, everything else reads `path`. */
export type GroupRuleMatch = 'START' | 'END' | 'REGEX' | 'TAG' | 'TAGALL' | 'EXACT' export type GroupRuleMatch = 'START' | 'END' | 'SUBTREE' | 'REGEX' | 'TAG' | 'TAGALL' | 'EXACT'
/** Whether a matching rule grants, denies, or unconditionally grants its roles. */ /** Whether a matching rule grants, denies, or unconditionally grants its roles. */
export type GroupRuleMode = 'ALLOW' | 'DENY' | 'FORCEALLOW' export type GroupRuleMode = 'ALLOW' | 'DENY' | 'FORCEALLOW'

@ -224,9 +224,15 @@ Website: https://montreal.ca
text-align: center; text-align: center;
} }
/*
Padded on every side, so the picture reads as something sitting in the box rather than as a
lid on the rows below it. The bottom edge matters as much as the others: the first row of the
list draws no line over it — the card's own border is what closes the header above — so
without it the facts start immediately under the image.
*/
figure { figure {
margin: 0; margin: 0;
padding: 12px 12px 0; padding: 12px;
text-align: center; text-align: center;
} }

@ -403,6 +403,7 @@
:options="[ :options="[
{ label: t('admin.groups.ruleMatchStart'), value: 'START' }, { label: t('admin.groups.ruleMatchStart'), value: 'START' },
{ label: t('admin.groups.ruleMatchEnd'), value: 'END' }, { label: t('admin.groups.ruleMatchEnd'), value: 'END' },
{ label: t('admin.groups.ruleMatchSubtree'), value: 'SUBTREE' },
{ label: t('admin.groups.ruleMatchRegex'), value: 'REGEX' }, { label: t('admin.groups.ruleMatchRegex'), value: 'REGEX' },
{ label: t('admin.groups.ruleMatchTag'), value: 'TAG' }, { label: t('admin.groups.ruleMatchTag'), value: 'TAG' },
{ label: t('admin.groups.ruleMatchTagAll'), value: 'TAGALL' }, { label: t('admin.groups.ruleMatchTagAll'), value: 'TAGALL' },
@ -439,7 +440,9 @@
standout standout
v-model="rule.path" v-model="rule.path"
dense dense
:prefix="[`START`, `REGEX`, `EXACT`].includes(rule.match) ? `/` : null" :prefix="
[`START`, `SUBTREE`, `REGEX`, `EXACT`].includes(rule.match) ? `/` : null
"
:suffix="rule.match === `REGEX` ? `/` : null" :suffix="rule.match === `REGEX` ? `/` : null"
:aria-label="t(`admin.groups.rulePath`)" /> :aria-label="t(`admin.groups.rulePath`)" />
</w-card-section> </w-card-section>
@ -1265,7 +1268,7 @@ async function importRules() {
id: uuid(), id: uuid(),
name: r.name || t('admin.groups.ruleUntitled'), name: r.name || t('admin.groups.ruleUntitled'),
mode: ['ALLOW', 'DENY', 'FORCEALLOW'].includes(r.mode) ? r.mode : 'DENY', mode: ['ALLOW', 'DENY', 'FORCEALLOW'].includes(r.mode) ? r.mode : 'DENY',
match: ['START', 'END', 'REGEX', 'TAG', 'TAGALL', 'EXACT'].includes(r.match) match: ['START', 'END', 'SUBTREE', 'REGEX', 'TAG', 'TAGALL', 'EXACT'].includes(r.match)
? r.match ? r.match
: 'START', : 'START',
roles: r.roles || [], roles: r.roles || [],

@ -823,7 +823,8 @@ $timeline-turn: 16px;
/* The subway line: one continuous rule behind the dots, drawn by the list rather than the items. */ /* The subway line: one continuous rule behind the dots, drawn by the list rather than the items. */
&-timeline { &-timeline {
position: relative; position: relative;
padding: 1rem 0; /* -> The extra bottom padding is what the line's turn stops short of; see `bottom` below */
padding: 1rem 0 calc(#{$timeline-turn} + 1rem);
/* /*
The line: down behind the dots, then a quarter turn out to the left edge rather than stopping The line: down behind the dots, then a quarter turn out to the left edge rather than stopping
@ -843,7 +844,12 @@ $timeline-turn: 16px;
content: ''; content: '';
position: absolute; position: absolute;
top: 0; top: 0;
bottom: 0; /*
Held off the bottom edge: with enough entries to fill the drawer the turn would otherwise
run into the bottom of the overlay, reading as a line that was cut off rather than one that
ended.
*/
bottom: $timeline-turn;
left: 0; left: 0;
width: calc(1rem + 14px + 1px); width: calc(1rem + 14px + 1px);
border-right: 2px solid $timeline-line; border-right: 2px solid $timeline-line;

Loading…
Cancel
Save