mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 21:45:31 +09:00
feat(threads): thread and forum API, admin, schemas and docs (#3251)
This commit is contained in:
1 parent
5f4295e399
commit
3f4160b138
495 files changed
+39232
-1215
No files matched your search
@@ -3,6 +3,7 @@
|
||||
import type {ChannelID, GuildID, UserID} from '@app/api/BrandedTypes';
|
||||
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
|
||||
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {ThreadPermissionFlags} from '@fluxer/constants/src/ThreadPermissionUtils';
|
||||
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
|
||||
|
||||
interface PermissionsDiff {
|
||||
@@ -10,10 +11,17 @@ interface PermissionsDiff {
|
||||
removed: Array<string>;
|
||||
}
|
||||
|
||||
export function computePermissionsDiff(oldPermissions: bigint, newPermissions: bigint): PermissionsDiff {
|
||||
export function computePermissionsDiff(
|
||||
oldPermissions: bigint,
|
||||
newPermissions: bigint,
|
||||
opts?: {threads?: boolean},
|
||||
): PermissionsDiff {
|
||||
const added: Array<string> = [];
|
||||
const removed: Array<string> = [];
|
||||
for (const [name, value] of Object.entries(Permissions)) {
|
||||
const entries = opts?.threads
|
||||
? [...Object.entries(Permissions), ...Object.entries(ThreadPermissionFlags)]
|
||||
: Object.entries(Permissions);
|
||||
for (const [name, value] of entries) {
|
||||
const hadPermission = (oldPermissions & value) !== 0n;
|
||||
const hasPermission = (newPermissions & value) !== 0n;
|
||||
if (!hadPermission && hasPermission) {
|
||||
|
||||
@@ -1,22 +1,48 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {ALL_PERMISSIONS, Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {Headers} from '@fluxer/constants/src/Headers';
|
||||
import {THREAD_AWARE_ALL_PERMISSIONS, THREAD_PERMISSIONS} from '@fluxer/constants/src/ThreadPermissionUtils';
|
||||
|
||||
export const CLIENT_FEATURES_HEADER = Headers.X_FLUXER_FEATURES;
|
||||
export const CHANNEL_THREADS_CLIENT_FEATURE = 'channel_threads';
|
||||
|
||||
export type ThreadPermissionMode = 'control' | 'active' | 'retired';
|
||||
|
||||
export interface ProtectedBitActor {
|
||||
clientFeatures: ReadonlySet<string>;
|
||||
threadBits?: {mode: ThreadPermissionMode; writer: boolean};
|
||||
}
|
||||
|
||||
function threadBitsWritable(actor: ProtectedBitActor): boolean {
|
||||
const threadBits = actor.threadBits;
|
||||
if (!threadBits || threadBits.mode === 'control') return true;
|
||||
return threadBits.mode === 'active' && threadBits.writer;
|
||||
}
|
||||
|
||||
export function protectedThreadBits(actor: ProtectedBitActor): bigint {
|
||||
return threadBitsWritable(actor) ? 0n : THREAD_PERMISSIONS;
|
||||
}
|
||||
|
||||
export function permissionWriteMask(actor: ProtectedBitActor): bigint {
|
||||
return !actor.threadBits || actor.threadBits.mode === 'control' ? ALL_PERMISSIONS : THREAD_AWARE_ALL_PERMISSIONS;
|
||||
}
|
||||
|
||||
const PERMISSION_BIT_CLIENT_FEATURES: ReadonlyArray<{
|
||||
bit: bigint;
|
||||
feature: string;
|
||||
}> = [{bit: Permissions.VIEW_CHANNEL_MEMBERS, feature: 'view_channel_members_permission'}];
|
||||
writable: (actor: ProtectedBitActor) => boolean;
|
||||
}> = [
|
||||
{
|
||||
bit: Permissions.VIEW_CHANNEL_MEMBERS,
|
||||
writable: (actor) => actor.clientFeatures.has('view_channel_members_permission'),
|
||||
},
|
||||
{bit: THREAD_PERMISSIONS, writable: threadBitsWritable},
|
||||
];
|
||||
|
||||
export function applyProtectedRolePermissions(
|
||||
requested: bigint,
|
||||
existing: bigint,
|
||||
clientFeatures: ReadonlySet<string>,
|
||||
): bigint {
|
||||
export function applyProtectedRolePermissions(requested: bigint, existing: bigint, actor: ProtectedBitActor): bigint {
|
||||
let result = requested;
|
||||
for (const {bit, feature} of PERMISSION_BIT_CLIENT_FEATURES) {
|
||||
if (clientFeatures.has(feature)) continue;
|
||||
for (const {bit, writable} of PERMISSION_BIT_CLIENT_FEATURES) {
|
||||
if (writable(actor)) continue;
|
||||
result = (result & ~bit) | (existing & bit);
|
||||
}
|
||||
return result;
|
||||
@@ -31,15 +57,15 @@ export function applyProtectedOverwriteBits(
|
||||
allow: bigint;
|
||||
deny: bigint;
|
||||
},
|
||||
clientFeatures: ReadonlySet<string>,
|
||||
actor: ProtectedBitActor,
|
||||
): {
|
||||
allow: bigint;
|
||||
deny: bigint;
|
||||
} {
|
||||
let allow = requested.allow;
|
||||
let deny = requested.deny;
|
||||
for (const {bit, feature} of PERMISSION_BIT_CLIENT_FEATURES) {
|
||||
if (clientFeatures.has(feature)) continue;
|
||||
for (const {bit, writable} of PERMISSION_BIT_CLIENT_FEATURES) {
|
||||
if (writable(actor)) continue;
|
||||
allow = (allow & ~bit) | (existing.allow & bit);
|
||||
deny = (deny & ~bit) | (existing.deny & bit);
|
||||
}
|
||||
@@ -50,9 +76,11 @@ const MAX_CLIENT_FEATURES = 64;
|
||||
const MAX_CLIENT_FEATURE_LENGTH = 64;
|
||||
const VALID_FEATURE_NAME = /^[a-z0-9_]+$/;
|
||||
|
||||
const NO_CLIENT_FEATURES: ReadonlySet<string> = new Set();
|
||||
|
||||
export function parseClientFeaturesHeader(headerValue: string | null | undefined): ReadonlySet<string> {
|
||||
if (!headerValue) {
|
||||
return new Set();
|
||||
return NO_CLIENT_FEATURES;
|
||||
}
|
||||
const features = new Set<string>();
|
||||
for (const raw of headerValue.split(',')) {
|
||||
|
||||
@@ -42,33 +42,33 @@ describe('applyProtectedRolePermissions', () => {
|
||||
it('preserves protected bits when client lacks the feature', () => {
|
||||
const existing = VCM | OTHER_BIT;
|
||||
const requested = OTHER_BIT;
|
||||
const result = applyProtectedRolePermissions(requested, existing, new Set());
|
||||
const result = applyProtectedRolePermissions(requested, existing, {clientFeatures: new Set()});
|
||||
expect((result & VCM) === VCM).toBe(true);
|
||||
expect((result & OTHER_BIT) === OTHER_BIT).toBe(true);
|
||||
});
|
||||
it('respects client-driven clears when feature is opted in', () => {
|
||||
const existing = VCM | OTHER_BIT;
|
||||
const requested = OTHER_BIT;
|
||||
const result = applyProtectedRolePermissions(requested, existing, new Set([FEATURE]));
|
||||
const result = applyProtectedRolePermissions(requested, existing, {clientFeatures: new Set([FEATURE])});
|
||||
expect((result & VCM) === 0n).toBe(true);
|
||||
expect((result & OTHER_BIT) === OTHER_BIT).toBe(true);
|
||||
});
|
||||
it('does not invent bits the existing value did not have', () => {
|
||||
const existing = OTHER_BIT;
|
||||
const requested = OTHER_BIT;
|
||||
const result = applyProtectedRolePermissions(requested, existing, new Set());
|
||||
const result = applyProtectedRolePermissions(requested, existing, {clientFeatures: new Set()});
|
||||
expect((result & VCM) === 0n).toBe(true);
|
||||
});
|
||||
it('also blocks an old client from setting the bit (the bit is fully invisible without the feature)', () => {
|
||||
const existing = 0n;
|
||||
const requested = VCM;
|
||||
const result = applyProtectedRolePermissions(requested, existing, new Set());
|
||||
const result = applyProtectedRolePermissions(requested, existing, {clientFeatures: new Set()});
|
||||
expect((result & VCM) === 0n).toBe(true);
|
||||
});
|
||||
it('lets a feature-aware client set the bit', () => {
|
||||
const existing = 0n;
|
||||
const requested = VCM;
|
||||
const result = applyProtectedRolePermissions(requested, existing, new Set([FEATURE]));
|
||||
const result = applyProtectedRolePermissions(requested, existing, {clientFeatures: new Set([FEATURE])});
|
||||
expect((result & VCM) === VCM).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -77,51 +77,51 @@ describe('applyProtectedOverwriteBits', () => {
|
||||
it('preserves bit on allow when feature absent', () => {
|
||||
const existing = {allow: VCM, deny: 0n};
|
||||
const requested = {allow: 0n, deny: 0n};
|
||||
const result = applyProtectedOverwriteBits(requested, existing, new Set());
|
||||
const result = applyProtectedOverwriteBits(requested, existing, {clientFeatures: new Set()});
|
||||
expect((result.allow & VCM) === VCM).toBe(true);
|
||||
expect((result.deny & VCM) === 0n).toBe(true);
|
||||
});
|
||||
it('preserves bit on deny when feature absent (the security-relevant case)', () => {
|
||||
const existing = {allow: 0n, deny: VCM};
|
||||
const requested = {allow: 0n, deny: 0n};
|
||||
const result = applyProtectedOverwriteBits(requested, existing, new Set());
|
||||
const result = applyProtectedOverwriteBits(requested, existing, {clientFeatures: new Set()});
|
||||
expect((result.deny & VCM) === VCM).toBe(true);
|
||||
});
|
||||
it('preserves the bit on whichever side it was set', () => {
|
||||
const existing = {allow: VCM | OTHER_BIT, deny: 0n};
|
||||
const requested = {allow: OTHER_BIT, deny: 0n};
|
||||
const result = applyProtectedOverwriteBits(requested, existing, new Set());
|
||||
const result = applyProtectedOverwriteBits(requested, existing, {clientFeatures: new Set()});
|
||||
expect((result.allow & VCM) === VCM).toBe(true);
|
||||
expect((result.allow & OTHER_BIT) === OTHER_BIT).toBe(true);
|
||||
});
|
||||
it('respects client-driven clears when feature is opted in (allow)', () => {
|
||||
const existing = {allow: VCM, deny: 0n};
|
||||
const requested = {allow: 0n, deny: 0n};
|
||||
const result = applyProtectedOverwriteBits(requested, existing, new Set([FEATURE]));
|
||||
const result = applyProtectedOverwriteBits(requested, existing, {clientFeatures: new Set([FEATURE])});
|
||||
expect((result.allow & VCM) === 0n).toBe(true);
|
||||
});
|
||||
it('respects client-driven clears when feature is opted in (deny)', () => {
|
||||
const existing = {allow: 0n, deny: VCM};
|
||||
const requested = {allow: 0n, deny: 0n};
|
||||
const result = applyProtectedOverwriteBits(requested, existing, new Set([FEATURE]));
|
||||
const result = applyProtectedOverwriteBits(requested, existing, {clientFeatures: new Set([FEATURE])});
|
||||
expect((result.deny & VCM) === 0n).toBe(true);
|
||||
});
|
||||
it('handles missing existing overwrite (treat as 0n) and blocks set without feature', () => {
|
||||
const existing = {allow: 0n, deny: 0n};
|
||||
const requested = {allow: VCM, deny: 0n};
|
||||
const result = applyProtectedOverwriteBits(requested, existing, new Set());
|
||||
const result = applyProtectedOverwriteBits(requested, existing, {clientFeatures: new Set()});
|
||||
expect((result.allow & VCM) === 0n).toBe(true);
|
||||
});
|
||||
it('lets a feature-aware client set the bit on a fresh overwrite', () => {
|
||||
const existing = {allow: 0n, deny: 0n};
|
||||
const requested = {allow: VCM, deny: 0n};
|
||||
const result = applyProtectedOverwriteBits(requested, existing, new Set([FEATURE]));
|
||||
const result = applyProtectedOverwriteBits(requested, existing, {clientFeatures: new Set([FEATURE])});
|
||||
expect((result.allow & VCM) === VCM).toBe(true);
|
||||
});
|
||||
it('does not flip bits between allow and deny', () => {
|
||||
const existing = {allow: 0n, deny: VCM};
|
||||
const requested = {allow: 0n, deny: 0n};
|
||||
const result = applyProtectedOverwriteBits(requested, existing, new Set());
|
||||
const result = applyProtectedOverwriteBits(requested, existing, {clientFeatures: new Set()});
|
||||
expect((result.allow & VCM) === 0n).toBe(true);
|
||||
expect((result.deny & VCM) === VCM).toBe(true);
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user