@meith/authorization 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +165 -0
- package/package.json +24 -0
- package/src/authorizer.ts +569 -0
- package/src/combine.ts +43 -0
- package/src/fixture.ts +226 -0
- package/src/index.ts +38 -0
- package/src/matrix-editor.ts +145 -0
- package/src/matrix.fixture.ts +131 -0
- package/src/memory-source.ts +68 -0
- package/src/resolve.ts +45 -0
- package/src/types.ts +158 -0
|
@@ -0,0 +1,569 @@
|
|
|
1
|
+
import {
|
|
2
|
+
authorFilterFrom,
|
|
3
|
+
type ContentScope,
|
|
4
|
+
contentScopeFrom,
|
|
5
|
+
ForbiddenError,
|
|
6
|
+
type ForumPermissions,
|
|
7
|
+
NO_THREAD_AUDIENCE,
|
|
8
|
+
type ThreadAudience,
|
|
9
|
+
type ThreadAuthorFilter,
|
|
10
|
+
unrestrictedAudience,
|
|
11
|
+
} from '@meith/core'
|
|
12
|
+
|
|
13
|
+
import { indexOverrides, resolveForumMatrix } from './resolve'
|
|
14
|
+
import type {
|
|
15
|
+
Action,
|
|
16
|
+
Actor,
|
|
17
|
+
AuthorizationSource,
|
|
18
|
+
ModeratedTarget,
|
|
19
|
+
NumericGlobalPermission,
|
|
20
|
+
Target,
|
|
21
|
+
Visible,
|
|
22
|
+
} from './types'
|
|
23
|
+
import { hasAnyModeratorRight, type ModeratorRights, NO_MODERATOR_RIGHTS } from './types'
|
|
24
|
+
|
|
25
|
+
export interface BypassEvent {
|
|
26
|
+
readonly kind: 'administrator' | 'super_moderator'
|
|
27
|
+
readonly userId: number | null
|
|
28
|
+
readonly action: Action
|
|
29
|
+
readonly forumId: number | undefined
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface AuthorizerOptions {
|
|
33
|
+
onBypass?: (event: BypassEvent) => void
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const FORUM_SCOPED: ReadonlySet<Action> = new Set<Action>([
|
|
37
|
+
'forum.view',
|
|
38
|
+
'thread.view',
|
|
39
|
+
'thread.viewOthers',
|
|
40
|
+
'thread.post',
|
|
41
|
+
'reply.post',
|
|
42
|
+
'poll.post',
|
|
43
|
+
'poll.vote',
|
|
44
|
+
'thread.rate',
|
|
45
|
+
'post.editOwn',
|
|
46
|
+
'post.editOthers',
|
|
47
|
+
'post.deleteOwn',
|
|
48
|
+
'post.softDelete',
|
|
49
|
+
'post.restore',
|
|
50
|
+
'content.viewUnapproved',
|
|
51
|
+
'content.viewDeleted',
|
|
52
|
+
'content.approve',
|
|
53
|
+
'thread.lock',
|
|
54
|
+
'thread.stick',
|
|
55
|
+
'thread.move',
|
|
56
|
+
'thread.delete',
|
|
57
|
+
'thread.restore',
|
|
58
|
+
'thread.merge',
|
|
59
|
+
'thread.split',
|
|
60
|
+
'attachment.upload',
|
|
61
|
+
'attachment.download',
|
|
62
|
+
'forum.search',
|
|
63
|
+
'forum.subscribe',
|
|
64
|
+
])
|
|
65
|
+
|
|
66
|
+
const ADMIN_ALWAYS: ReadonlySet<Action> = new Set<Action>([
|
|
67
|
+
...FORUM_SCOPED,
|
|
68
|
+
'modcp.access',
|
|
69
|
+
'profile.view',
|
|
70
|
+
'memberlist.view',
|
|
71
|
+
'pm.use',
|
|
72
|
+
'content.report',
|
|
73
|
+
'reputation.give',
|
|
74
|
+
'signature.use',
|
|
75
|
+
'flood.bypass',
|
|
76
|
+
'board.viewOffline',
|
|
77
|
+
])
|
|
78
|
+
|
|
79
|
+
export class Authorizer {
|
|
80
|
+
constructor(
|
|
81
|
+
private readonly source: AuthorizationSource,
|
|
82
|
+
private readonly options: AuthorizerOptions = {},
|
|
83
|
+
) {}
|
|
84
|
+
|
|
85
|
+
can(actor: Actor, action: Action, target: Target = {}): boolean {
|
|
86
|
+
if (actor.state === 'banned') return false
|
|
87
|
+
if (actor.state === 'awaiting_activation' && !isReadAction(action)) {
|
|
88
|
+
return false
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (actor.global.isAdministrator === true && ADMIN_ALWAYS.has(action)) {
|
|
92
|
+
this.logBypass('administrator', actor, action, target.forumId)
|
|
93
|
+
return true
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (actor.global.isSuperModerator === true && FORUM_SCOPED.has(action)) {
|
|
97
|
+
this.logBypass('super_moderator', actor, action, target.forumId)
|
|
98
|
+
return true
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return FORUM_SCOPED.has(action)
|
|
102
|
+
? this.canForum(actor, action, target)
|
|
103
|
+
: this.canGlobal(actor, action)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
require(actor: Actor, action: Action, target: Target = {}): void {
|
|
107
|
+
if (!this.can(actor, action, target)) {
|
|
108
|
+
throw new ForbiddenError(`Not permitted: ${action}`, {
|
|
109
|
+
meta: { userId: actor.userId, action, forumId: target.forumId },
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
inAnyGroup(actor: Actor, groupIds: readonly number[]): boolean {
|
|
115
|
+
if (groupIds.length === 0) return true
|
|
116
|
+
return actor.groupIds.some((id) => groupIds.includes(id))
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async forumMatrix(actor: Actor, forumId: number): Promise<ForumPermissions> {
|
|
120
|
+
const chain = await this.source.ancestorChain(forumId)
|
|
121
|
+
const groups = await this.source.groupDefaults(actor.groupIds)
|
|
122
|
+
const overrides = await this.source.forumOverrides(chain, actor.groupIds)
|
|
123
|
+
return resolveForumMatrix(chain, groups, indexOverrides(overrides))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async visibleForumIds(actor: Actor): Promise<number[]> {
|
|
127
|
+
if (actor.global.isAdministrator === true) {
|
|
128
|
+
return [...(await this.source.allForumIds())]
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const chains = await this.source.allAncestorChains()
|
|
132
|
+
const groups = await this.source.groupDefaults(actor.groupIds)
|
|
133
|
+
|
|
134
|
+
const everyForumInvolved = [...new Set([...chains.values()].flat())]
|
|
135
|
+
const overrides = indexOverrides(
|
|
136
|
+
await this.source.forumOverrides(everyForumInvolved, actor.groupIds),
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
const visible: number[] = []
|
|
140
|
+
for (const [forumId, chain] of chains) {
|
|
141
|
+
const matrix = resolveForumMatrix(chain, groups, overrides)
|
|
142
|
+
if (matrix.canView === true) visible.push(forumId)
|
|
143
|
+
}
|
|
144
|
+
return visible
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async listingVisibility(actor: Actor): Promise<{
|
|
148
|
+
readonly visibleForumIds: number[]
|
|
149
|
+
readonly ownThreadsOnlyForumIds: number[]
|
|
150
|
+
}> {
|
|
151
|
+
if (actor.global.isAdministrator === true) {
|
|
152
|
+
return {
|
|
153
|
+
visibleForumIds: [...(await this.source.allForumIds())],
|
|
154
|
+
ownThreadsOnlyForumIds: [],
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const visibleForumIds: number[] = []
|
|
159
|
+
const ownThreadsOnlyForumIds: number[] = []
|
|
160
|
+
for (const target of await this.resolvedTargets(actor)) {
|
|
161
|
+
visibleForumIds.push(target.forumId)
|
|
162
|
+
if (this.can(actor, 'thread.view', target) && !this.can(actor, 'thread.viewOthers', target)) {
|
|
163
|
+
ownThreadsOnlyForumIds.push(target.forumId)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return { visibleForumIds, ownThreadsOnlyForumIds }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async moderatedForumIds(
|
|
170
|
+
actor: Actor,
|
|
171
|
+
right: keyof ModeratorRights = 'canApproveContent',
|
|
172
|
+
): Promise<number[]> {
|
|
173
|
+
if (actor.global.isAdministrator === true || actor.global.isSuperModerator === true) {
|
|
174
|
+
return [...(await this.source.allForumIds())]
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const [chains, groups, appointments] = await Promise.all([
|
|
178
|
+
this.source.allAncestorChains(),
|
|
179
|
+
this.source.groupDefaults(actor.groupIds),
|
|
180
|
+
this.source.moderatorAppointments(actor.userId, actor.groupIds),
|
|
181
|
+
])
|
|
182
|
+
|
|
183
|
+
const everyForumInvolved = [...new Set([...chains.values()].flat())]
|
|
184
|
+
const overrides = indexOverrides(
|
|
185
|
+
await this.source.forumOverrides(everyForumInvolved, actor.groupIds),
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
const approvesByAppointment = new Set<number>()
|
|
189
|
+
for (const [forumId, chain] of chains) {
|
|
190
|
+
for (const appointment of appointments) {
|
|
191
|
+
if (!appointment[right]) continue
|
|
192
|
+
if (appointment.forumId === forumId) {
|
|
193
|
+
approvesByAppointment.add(forumId)
|
|
194
|
+
} else if (appointment.cascadeToSubforums && chain.includes(appointment.forumId)) {
|
|
195
|
+
approvesByAppointment.add(forumId)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const moderated: number[] = []
|
|
201
|
+
for (const [forumId, chain] of chains) {
|
|
202
|
+
const matrix = resolveForumMatrix(chain, groups, overrides)
|
|
203
|
+
if (matrix.canView !== true) continue
|
|
204
|
+
const byGroup = right === 'canApproveContent' && matrix.canApproveContent === true
|
|
205
|
+
if (byGroup || approvesByAppointment.has(forumId)) moderated.push(forumId)
|
|
206
|
+
}
|
|
207
|
+
return moderated
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
applicableGroupRows<T extends { readonly groupId: number }>(
|
|
211
|
+
actor: Actor,
|
|
212
|
+
rows: readonly T[],
|
|
213
|
+
): readonly T[] {
|
|
214
|
+
return this.applicableGroupRowsForGroups(actor.groupIds, rows)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
applicableGroupRowsForGroups<T extends { readonly groupId: number }>(
|
|
218
|
+
groupIds: readonly number[],
|
|
219
|
+
rows: readonly T[],
|
|
220
|
+
): readonly T[] {
|
|
221
|
+
const mine = new Set(groupIds)
|
|
222
|
+
return rows.filter((row) => mine.has(row.groupId))
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async moderatorRightsIn(actor: Actor, forumId: number): Promise<ModeratorRights> {
|
|
226
|
+
if (actor.global.isAdministrator === true || actor.global.isSuperModerator === true) {
|
|
227
|
+
return ALL_MODERATOR_RIGHTS
|
|
228
|
+
}
|
|
229
|
+
if (actor.userId === null) return NO_MODERATOR_RIGHTS
|
|
230
|
+
|
|
231
|
+
const [chain, appointments] = await Promise.all([
|
|
232
|
+
this.source.ancestorChain(forumId),
|
|
233
|
+
this.source.moderatorAppointments(actor.userId, actor.groupIds),
|
|
234
|
+
])
|
|
235
|
+
if (chain.length === 0) return NO_MODERATOR_RIGHTS
|
|
236
|
+
|
|
237
|
+
let rights = NO_MODERATOR_RIGHTS
|
|
238
|
+
for (const appointment of appointments) {
|
|
239
|
+
const covers =
|
|
240
|
+
appointment.forumId === forumId ||
|
|
241
|
+
(appointment.cascadeToSubforums && chain.includes(appointment.forumId))
|
|
242
|
+
if (covers) rights = unionRights(rights, appointment)
|
|
243
|
+
}
|
|
244
|
+
return rights
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async forumIdsWhere(actor: Actor, action: Action): Promise<number[]> {
|
|
248
|
+
if (!FORUM_SCOPED.has(action)) {
|
|
249
|
+
throw new Error(`forumIdsWhere is only meaningful for forum-scoped actions: ${action}`)
|
|
250
|
+
}
|
|
251
|
+
if (actor.state === 'banned') return []
|
|
252
|
+
|
|
253
|
+
if (actor.global.isAdministrator === true && ADMIN_ALWAYS.has(action)) {
|
|
254
|
+
this.logBypass('administrator', actor, action, undefined)
|
|
255
|
+
return [...(await this.source.allForumIds())]
|
|
256
|
+
}
|
|
257
|
+
if (actor.global.isSuperModerator === true) {
|
|
258
|
+
this.logBypass('super_moderator', actor, action, undefined)
|
|
259
|
+
return [...(await this.source.allForumIds())]
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const permitted: number[] = []
|
|
263
|
+
for (const target of await this.resolvedTargets(actor)) {
|
|
264
|
+
if (this.can(actor, action, target)) permitted.push(target.forumId)
|
|
265
|
+
}
|
|
266
|
+
return permitted
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async threadAudience(actor: Actor): Promise<ThreadAudience> {
|
|
270
|
+
if (actor.state === 'banned') {
|
|
271
|
+
return { ...NO_THREAD_AUDIENCE, viewerUserId: actor.userId }
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (actor.global.isAdministrator === true) {
|
|
275
|
+
this.logBypass('administrator', actor, 'thread.view', undefined)
|
|
276
|
+
return unrestrictedAudience([...(await this.source.allForumIds())], actor.userId)
|
|
277
|
+
}
|
|
278
|
+
if (actor.global.isSuperModerator === true) {
|
|
279
|
+
this.logBypass('super_moderator', actor, 'thread.view', undefined)
|
|
280
|
+
return unrestrictedAudience([...(await this.source.allForumIds())], actor.userId)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const forumIds: number[] = []
|
|
284
|
+
const ownThreadsOnlyForumIds: number[] = []
|
|
285
|
+
for (const target of await this.resolvedTargets(actor)) {
|
|
286
|
+
if (!this.can(actor, 'thread.view', target)) continue
|
|
287
|
+
forumIds.push(target.forumId)
|
|
288
|
+
if (!this.can(actor, 'thread.viewOthers', target)) {
|
|
289
|
+
ownThreadsOnlyForumIds.push(target.forumId)
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return { forumIds, ownThreadsOnlyForumIds, viewerUserId: actor.userId }
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
authorFilter(actor: Actor, target: Target): ThreadAuthorFilter {
|
|
296
|
+
return authorFilterFrom({
|
|
297
|
+
seesOthersThreads: this.can(actor, 'thread.viewOthers', target),
|
|
298
|
+
viewerUserId: actor.userId,
|
|
299
|
+
})
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async authorFilterIn(
|
|
303
|
+
actor: Actor,
|
|
304
|
+
forumId: number,
|
|
305
|
+
forum: ForumPermissions,
|
|
306
|
+
): Promise<ThreadAuthorFilter> {
|
|
307
|
+
return this.authorFilter(actor, await this.moderatorTargetIn(actor, forumId, forum))
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
private async resolvedTargets(actor: Actor): Promise<readonly ModeratedTarget[]> {
|
|
311
|
+
const [chains, groups, appointments] = await Promise.all([
|
|
312
|
+
this.source.allAncestorChains(),
|
|
313
|
+
this.source.groupDefaults(actor.groupIds),
|
|
314
|
+
this.source.moderatorAppointments(actor.userId, actor.groupIds),
|
|
315
|
+
])
|
|
316
|
+
|
|
317
|
+
const everyForumInvolved = [...new Set([...chains.values()].flat())]
|
|
318
|
+
const overrides = indexOverrides(
|
|
319
|
+
await this.source.forumOverrides(everyForumInvolved, actor.groupIds),
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
const targets: ModeratedTarget[] = []
|
|
323
|
+
for (const [forumId, chain] of chains) {
|
|
324
|
+
const forum = resolveForumMatrix(chain, groups, overrides)
|
|
325
|
+
if (forum.canView !== true) continue
|
|
326
|
+
|
|
327
|
+
let moderatorRights = NO_MODERATOR_RIGHTS
|
|
328
|
+
let appointed = false
|
|
329
|
+
for (const appointment of appointments) {
|
|
330
|
+
const covers =
|
|
331
|
+
appointment.forumId === forumId ||
|
|
332
|
+
(appointment.cascadeToSubforums && chain.includes(appointment.forumId))
|
|
333
|
+
if (!covers) continue
|
|
334
|
+
appointed = true
|
|
335
|
+
moderatorRights = unionRights(moderatorRights, appointment)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
targets.push({
|
|
339
|
+
forumId,
|
|
340
|
+
forum,
|
|
341
|
+
moderatorRights,
|
|
342
|
+
isForumModerator: appointed,
|
|
343
|
+
})
|
|
344
|
+
}
|
|
345
|
+
return targets
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
contentScope(actor: Actor, target: Target): ContentScope {
|
|
349
|
+
return contentScopeFrom({
|
|
350
|
+
seesUnapproved: this.can(actor, 'content.viewUnapproved', target),
|
|
351
|
+
seesDeleted: this.can(actor, 'content.viewDeleted', target),
|
|
352
|
+
})
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async moderatorTargetIn(
|
|
356
|
+
actor: Actor,
|
|
357
|
+
forumId: number,
|
|
358
|
+
forum: ForumPermissions,
|
|
359
|
+
): Promise<ModeratedTarget> {
|
|
360
|
+
const moderatorRights = await this.moderatorRightsIn(actor, forumId)
|
|
361
|
+
return {
|
|
362
|
+
forumId,
|
|
363
|
+
forum,
|
|
364
|
+
moderatorRights,
|
|
365
|
+
isForumModerator: hasAnyModeratorRight(moderatorRights),
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async contentScopeIn(
|
|
370
|
+
actor: Actor,
|
|
371
|
+
forumId: number,
|
|
372
|
+
forum: ForumPermissions,
|
|
373
|
+
): Promise<ContentScope> {
|
|
374
|
+
return this.contentScope(actor, await this.moderatorTargetIn(actor, forumId, forum))
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
globalLimit(actor: Actor, key: NumericGlobalPermission): number {
|
|
378
|
+
const value = actor.global[key]
|
|
379
|
+
return typeof value === 'number' ? value : 0
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
filterVisible<T extends Visible>(
|
|
383
|
+
_actor: Actor,
|
|
384
|
+
visibleForumIds: ReadonlySet<number>,
|
|
385
|
+
rows: readonly T[],
|
|
386
|
+
): T[] {
|
|
387
|
+
return rows.filter((r) => visibleForumIds.has(r.forumId))
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
private canForum(actor: Actor, action: Action, target: Target): boolean {
|
|
391
|
+
const forum = target.forum
|
|
392
|
+
if (!forum) {
|
|
393
|
+
throw new Error(
|
|
394
|
+
`Forum-scoped action "${action}" requires target.forum (resolved matrix). ` +
|
|
395
|
+
`Call authorizer.forumMatrix() first.`,
|
|
396
|
+
)
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (forum.canView !== true) return false
|
|
400
|
+
|
|
401
|
+
if (
|
|
402
|
+
target.passwordRequired === true &&
|
|
403
|
+
target.passwordSatisfied !== true &&
|
|
404
|
+
action !== 'forum.view'
|
|
405
|
+
) {
|
|
406
|
+
return false
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const ownsContent = target.ownerId != null && target.ownerId === actor.userId
|
|
410
|
+
const ownsThread = target.threadAuthorId != null && target.threadAuthorId === actor.userId
|
|
411
|
+
|
|
412
|
+
switch (action) {
|
|
413
|
+
case 'forum.view':
|
|
414
|
+
return true
|
|
415
|
+
case 'thread.view': {
|
|
416
|
+
if (forum.canViewThreads !== true) return false
|
|
417
|
+
const author = target.threadAuthorId
|
|
418
|
+
if (author === undefined) return true
|
|
419
|
+
if (author !== null && author === actor.userId) return true
|
|
420
|
+
return this.canForum(actor, 'thread.viewOthers', target)
|
|
421
|
+
}
|
|
422
|
+
case 'thread.viewOthers':
|
|
423
|
+
return (
|
|
424
|
+
forum.canViewThreads === true &&
|
|
425
|
+
(target.isForumModerator === true || forum.canViewOthersThreads === true)
|
|
426
|
+
)
|
|
427
|
+
case 'forum.search':
|
|
428
|
+
return forum.canSearch === true
|
|
429
|
+
case 'forum.subscribe':
|
|
430
|
+
return forum.canSubscribe === true
|
|
431
|
+
case 'thread.post':
|
|
432
|
+
return forum.canPostThreads === true
|
|
433
|
+
case 'reply.post':
|
|
434
|
+
return forum.canPostReplies === true
|
|
435
|
+
case 'poll.post':
|
|
436
|
+
return forum.canPostPolls === true
|
|
437
|
+
case 'poll.vote':
|
|
438
|
+
return forum.canVotePolls === true
|
|
439
|
+
case 'thread.rate':
|
|
440
|
+
return forum.canRateThreads === true
|
|
441
|
+
case 'attachment.upload':
|
|
442
|
+
return forum.canUploadAttachments === true
|
|
443
|
+
case 'attachment.download':
|
|
444
|
+
return forum.canDownloadAttachments === true
|
|
445
|
+
case 'post.editOwn':
|
|
446
|
+
return ownsContent && forum.canEditOwnPosts === true
|
|
447
|
+
case 'post.deleteOwn':
|
|
448
|
+
return ownsContent && forum.canDeleteOwnPosts === true
|
|
449
|
+
case 'post.editOthers':
|
|
450
|
+
return (
|
|
451
|
+
(target.moderatorRights?.canEditPosts === true || forum.canEditOthersPosts === true) &&
|
|
452
|
+
!ownsContent
|
|
453
|
+
)
|
|
454
|
+
case 'post.softDelete':
|
|
455
|
+
return (
|
|
456
|
+
target.moderatorRights?.canSoftDeletePosts === true || forum.canSoftDeletePosts === true
|
|
457
|
+
)
|
|
458
|
+
case 'post.restore':
|
|
459
|
+
return target.moderatorRights?.canRestorePosts === true || forum.canSoftDeletePosts === true
|
|
460
|
+
case 'content.viewUnapproved':
|
|
461
|
+
return target.isForumModerator === true || forum.canViewUnapproved === true
|
|
462
|
+
case 'content.viewDeleted':
|
|
463
|
+
return target.isForumModerator === true || forum.canViewDeleted === true
|
|
464
|
+
case 'content.approve':
|
|
465
|
+
return (
|
|
466
|
+
target.moderatorRights?.canApproveContent === true || forum.canApproveContent === true
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
case 'thread.lock':
|
|
470
|
+
return target.moderatorRights?.canOpenCloseThreads === true
|
|
471
|
+
case 'thread.stick':
|
|
472
|
+
return target.moderatorRights?.canStickThreads === true
|
|
473
|
+
case 'thread.move':
|
|
474
|
+
return target.moderatorRights?.canMoveThreads === true
|
|
475
|
+
case 'thread.delete':
|
|
476
|
+
return (
|
|
477
|
+
target.moderatorRights?.canSoftDeletePosts === true ||
|
|
478
|
+
(ownsThread && forum.canDeleteOwnThreads === true)
|
|
479
|
+
)
|
|
480
|
+
case 'thread.restore':
|
|
481
|
+
return target.moderatorRights?.canRestorePosts === true
|
|
482
|
+
case 'thread.merge':
|
|
483
|
+
return target.moderatorRights?.canMergeThreads === true
|
|
484
|
+
case 'thread.split':
|
|
485
|
+
return target.moderatorRights?.canSplitThreads === true
|
|
486
|
+
default: {
|
|
487
|
+
const _exhaustive: never = action as never
|
|
488
|
+
return Boolean(_exhaustive)
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
private canGlobal(actor: Actor, action: Action): boolean {
|
|
494
|
+
switch (action) {
|
|
495
|
+
case 'profile.view':
|
|
496
|
+
return actor.global.canViewProfiles === true
|
|
497
|
+
case 'memberlist.view':
|
|
498
|
+
return actor.global.canViewMemberList === true
|
|
499
|
+
case 'pm.use':
|
|
500
|
+
return actor.global.canUsePrivateMessages === true
|
|
501
|
+
case 'avatar.upload':
|
|
502
|
+
return actor.global.canUploadAvatar === true
|
|
503
|
+
case 'content.report':
|
|
504
|
+
return actor.global.canReportContent === true
|
|
505
|
+
case 'user.warn':
|
|
506
|
+
return actor.global.canWarnUsers === true
|
|
507
|
+
case 'reputation.give':
|
|
508
|
+
return actor.global.canGiveReputation === true
|
|
509
|
+
case 'signature.use':
|
|
510
|
+
return actor.global.canUseSignature === true
|
|
511
|
+
case 'modcp.access':
|
|
512
|
+
return actor.global.canAccessModCp === true
|
|
513
|
+
case 'admincp.access':
|
|
514
|
+
return actor.global.canAccessAdminCp === true
|
|
515
|
+
case 'flood.bypass':
|
|
516
|
+
return actor.global.canBypassFloodCheck === true
|
|
517
|
+
case 'board.viewOffline':
|
|
518
|
+
return actor.global.canViewBoardOffline === true
|
|
519
|
+
default:
|
|
520
|
+
return false
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
private logBypass(
|
|
525
|
+
kind: BypassEvent['kind'],
|
|
526
|
+
actor: Actor,
|
|
527
|
+
action: Action,
|
|
528
|
+
forumId: number | undefined,
|
|
529
|
+
): void {
|
|
530
|
+
this.options.onBypass?.({ kind, userId: actor.userId, action, forumId })
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function isReadAction(action: Action): boolean {
|
|
535
|
+
return (
|
|
536
|
+
action === 'forum.view' ||
|
|
537
|
+
action === 'thread.view' ||
|
|
538
|
+
action === 'thread.viewOthers' ||
|
|
539
|
+
action === 'forum.search' ||
|
|
540
|
+
action === 'profile.view' ||
|
|
541
|
+
action === 'memberlist.view'
|
|
542
|
+
)
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const ALL_MODERATOR_RIGHTS: ModeratorRights = {
|
|
546
|
+
canApproveContent: true,
|
|
547
|
+
canEditPosts: true,
|
|
548
|
+
canSoftDeletePosts: true,
|
|
549
|
+
canRestorePosts: true,
|
|
550
|
+
canOpenCloseThreads: true,
|
|
551
|
+
canStickThreads: true,
|
|
552
|
+
canMoveThreads: true,
|
|
553
|
+
canMergeThreads: true,
|
|
554
|
+
canSplitThreads: true,
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function unionRights(a: ModeratorRights, b: ModeratorRights): ModeratorRights {
|
|
558
|
+
return {
|
|
559
|
+
canApproveContent: a.canApproveContent || b.canApproveContent,
|
|
560
|
+
canEditPosts: a.canEditPosts || b.canEditPosts,
|
|
561
|
+
canSoftDeletePosts: a.canSoftDeletePosts || b.canSoftDeletePosts,
|
|
562
|
+
canRestorePosts: a.canRestorePosts || b.canRestorePosts,
|
|
563
|
+
canOpenCloseThreads: a.canOpenCloseThreads || b.canOpenCloseThreads,
|
|
564
|
+
canStickThreads: a.canStickThreads || b.canStickThreads,
|
|
565
|
+
canMoveThreads: a.canMoveThreads || b.canMoveThreads,
|
|
566
|
+
canMergeThreads: a.canMergeThreads || b.canMergeThreads,
|
|
567
|
+
canSplitThreads: a.canSplitThreads || b.canSplitThreads,
|
|
568
|
+
}
|
|
569
|
+
}
|
package/src/combine.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import {
|
|
2
|
+
emptyPermissionSet,
|
|
3
|
+
PERMISSION_FIELDS,
|
|
4
|
+
type PermissionField,
|
|
5
|
+
type PermissionSet,
|
|
6
|
+
} from '@meith/core'
|
|
7
|
+
|
|
8
|
+
export function combineGroupValue(
|
|
9
|
+
kind: PermissionField['kind'],
|
|
10
|
+
values: readonly (boolean | number)[],
|
|
11
|
+
): boolean | number {
|
|
12
|
+
switch (kind) {
|
|
13
|
+
case 'boolean': {
|
|
14
|
+
return values.some((v) => v === true)
|
|
15
|
+
}
|
|
16
|
+
case 'numeric': {
|
|
17
|
+
const nums = values.map(Number)
|
|
18
|
+
if (nums.some((n) => n === 0)) return 0
|
|
19
|
+
return nums.reduce((a, b) => Math.max(a, b), 0)
|
|
20
|
+
}
|
|
21
|
+
case 'negative': {
|
|
22
|
+
return values.every((v) => v === true)
|
|
23
|
+
}
|
|
24
|
+
default: {
|
|
25
|
+
const _exhaustive: never = kind
|
|
26
|
+
throw new Error(`Unhandled permission kind: ${String(_exhaustive)}`)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function combinePermissionSets(sets: readonly PermissionSet[]): PermissionSet {
|
|
32
|
+
if (sets.length === 0) return emptyPermissionSet()
|
|
33
|
+
if (sets.length === 1) return { ...sets[0]! }
|
|
34
|
+
|
|
35
|
+
const out = emptyPermissionSet() as Record<string, boolean | number>
|
|
36
|
+
|
|
37
|
+
for (const field of PERMISSION_FIELDS) {
|
|
38
|
+
const values = sets.map((s) => (s as Record<string, boolean | number>)[field.key]!)
|
|
39
|
+
out[field.key] = combineGroupValue(field.kind, values)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return out as PermissionSet
|
|
43
|
+
}
|