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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions src/db/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -349,9 +349,12 @@ export interface ServerChannelRecord {
/** Null means anybody holding send_messages, which is every channel unless an
operator narrows it. */
post_min_rank: number | null;
/** Null means the channel has no opinion. Never resolve a permission by
reading this; `channelPermissions.ts` is the one answer. */
/** Null means no scope of its own. Never resolve a permission by reading
this; `resolveChannelScopes` is the one answer, folder included. */
permission_scope_id: string | null;
/** Whether a channel with no scope of its own takes its folder's. False once
somebody picks its permissions, which only shows for Everyone. */
follows_folder: boolean;
/** Migrated into a scope on upgrade and unread afterwards. Kept so a rollback
still enforces the gate it had rather than losing it silently. */
view_min_rank: number | null;
Expand Down Expand Up @@ -403,6 +406,8 @@ export interface ServerSidebarItemRecord {
/** Only a channel may have one: the sidebar has one indent step, and a divider
inside a folder divides nothing. */
parent_item_id: string | null;
/** A folder's scope, taken by every channel in it that follows it. */
permission_scope_id: string | null;
created_at: Date;
updated_at: Date;
}
Expand Down
166 changes: 145 additions & 21 deletions src/db/sqlite/channelScopes.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,114 @@
import { randomUUID } from "crypto";

import { CHANNEL_PERMISSIONS, isChannelPermission, type ChannelPermission } from "../../constants/permissions";
import type { ChannelPermissionRuleRecord, ChannelPermissionScopeRecord, RuleEffect } from "../interfaces";
import type {
ChannelPermissionRuleRecord,
ChannelPermissionScopeRecord,
RuleEffect,
ServerChannelRecord,
ServerSidebarItemRecord,
} from "../interfaces";
import { fromIso, getSqliteDb, intToBool, toIso } from "./connection";

export interface ResolvedChannelScope {
/** What decides this channel. Null is every role's server-wide answer. */
scopeId: string | null;
/** The folder it sits in, or null at the top level. */
folderId: string | null;
followsFolder: boolean;
}

/**
* The one place a channel's scope is worked out: its folder's while it follows
* one, otherwise its own. `items` in listing order, since a channel's first row wins.
*/
export function resolveChannelScopes(
channels: readonly Pick<ServerChannelRecord, "channel_id" | "permission_scope_id" | "follows_folder">[],
items: readonly Pick<ServerSidebarItemRecord, "item_id" | "kind" | "channel_id" | "parent_item_id" | "permission_scope_id">[],
): Map<string, ResolvedChannelScope> {
const folderScopes = new Map<string, string | null>();
for (const it of items) if (it.kind === "folder") folderScopes.set(it.item_id, it.permission_scope_id ?? null);

const folderOf = new Map<string, string | null>();
for (const it of items) {
if (it.kind !== "channel" || !it.channel_id || folderOf.has(it.channel_id)) continue;
const parent = it.parent_item_id ?? null;
folderOf.set(it.channel_id, parent && folderScopes.has(parent) ? parent : null);
}

const resolved = new Map<string, ResolvedChannelScope>();
for (const c of channels) {
const folderId = folderOf.get(c.channel_id) ?? null;
// A scope of its own wins, so an older build writing one is never overridden.
const followsFolder = folderId !== null && !c.permission_scope_id && c.follows_folder;
resolved.set(c.channel_id, {
scopeId: followsFolder ? folderScopes.get(folderId as string) ?? null : c.permission_scope_id ?? null,
folderId,
followsFolder,
});
}
return resolved;
}

/** A private scope belongs to one channel or one folder, so it goes when the
last thing using it does. A template stays. */
export function dropPermissionScopeIfUnused(scopeId: string): void {
const db = getSqliteDb();
const scope = db
.prepare(`SELECT is_template FROM channel_permission_scopes WHERE scope_id = ?`)
.get(scopeId) as { is_template: number } | undefined;
if (!scope || scope.is_template) return;

const used = db
.prepare(
`SELECT (SELECT COUNT(*) FROM channels WHERE permission_scope_id = ?)
+ (SELECT COUNT(*) FROM sidebar_items WHERE permission_scope_id = ?) AS n`,
)
.get(scopeId, scopeId) as { n: number };
if (used.n > 0) return;

db.prepare(`DELETE FROM channel_permission_rules WHERE scope_id = ?`).run(scopeId);
db.prepare(`DELETE FROM channel_permission_scopes WHERE scope_id = ?`).run(scopeId);
}

/**
* Leaving a folder never opens a channel: one that followed a folder's scope and
* sits in none now keeps it as its own. Runs inside the caller's transaction.
*/
export function keepScopesOfChannelsLeavingFolders(
before: Map<string, ResolvedChannelScope>,
after: Map<string, ResolvedChannelScope>,
): void {
const db = getSqliteDb();
const now = toIso(new Date());

for (const [channelId, was] of before) {
const is = after.get(channelId);
if (!was.followsFolder || !was.scopeId || !is || is.folderId) continue;

const scope = db
.prepare(`SELECT is_template FROM channel_permission_scopes WHERE scope_id = ?`)
.get(was.scopeId) as { is_template: number } | undefined;
if (!scope) continue;

// A folder's private scope is copied, not shared: it dies with the folder.
let own = was.scopeId;
if (!scope.is_template) {
own = `scope_${randomUUID().slice(0, 12)}`;
db.prepare(
`INSERT INTO channel_permission_scopes (scope_id, name, is_template, is_system, created_at, updated_at)
VALUES (?, NULL, 0, 0, ?, ?)`,
).run(own, now, now);
db.prepare(
`INSERT INTO channel_permission_rules (scope_id, role_id, permission, effect, created_at)
SELECT ?, role_id, permission, effect, ? FROM channel_permission_rules WHERE scope_id = ?`,
).run(own, now, was.scopeId);
}
db.prepare(`UPDATE channels SET permission_scope_id = ?, follows_folder = 0, updated_at = ? WHERE channel_id = ?`)
.run(own, now, channelId);
}
}

function rowToScope(r: Record<string, unknown>): ChannelPermissionScopeRecord {
return {
scope_id: r.scope_id as string,
Expand Down Expand Up @@ -128,9 +233,13 @@ export async function replacePermissionRules(
}
}

/** Deleting the private scope it owned is part of this, since that belongs to
one channel and would be left unreachable. A template is left alone. */
export async function setChannelPermissionScope(channelId: string, scopeId: string | null): Promise<void> {
/** Any choice made here is the channel's own, Everyone included, unless
`followFolder` hands it back to its folder. The private scope it owned goes. */
export async function setChannelPermissionScope(
channelId: string,
scopeId: string | null,
{ followFolder = false }: { followFolder?: boolean } = {},
): Promise<void> {
const db = getSqliteDb();
const now = toIso(new Date());

Expand All @@ -140,37 +249,52 @@ export async function setChannelPermissionScope(channelId: string, scopeId: stri
.prepare(`SELECT permission_scope_id FROM channels WHERE channel_id = ?`)
.get(channelId) as { permission_scope_id: string | null } | undefined;

db.prepare(`UPDATE channels SET permission_scope_id = ?, updated_at = ? WHERE channel_id = ?`)
.run(scopeId, now, channelId);
db.prepare(`UPDATE channels SET permission_scope_id = ?, follows_folder = ?, updated_at = ? WHERE channel_id = ?`)
.run(scopeId, followFolder && !scopeId ? 1 : 0, now, channelId);

const old = previous?.permission_scope_id;
if (old && old !== scopeId) {
const stillUsed = db
.prepare(`SELECT COUNT(*) AS n FROM channels WHERE permission_scope_id = ?`)
.get(old) as { n: number };
const scope = db
.prepare(`SELECT is_template FROM channel_permission_scopes WHERE scope_id = ?`)
.get(old) as { is_template: number } | undefined;
if (scope && !scope.is_template && stillUsed.n === 0) {
db.prepare(`DELETE FROM channel_permission_rules WHERE scope_id = ?`).run(old);
db.prepare(`DELETE FROM channel_permission_scopes WHERE scope_id = ?`).run(old);
}
}
if (old && old !== scopeId) dropPermissionScopeIfUnused(old);
db.exec("COMMIT");
} catch (err) {
db.exec("ROLLBACK");
throw err;
}
}

/** The folder's half of `setChannelPermissionScope`. Its channels read it
through `resolveChannelScopes`, so nothing is written to them. */
export async function setFolderPermissionScope(folderItemId: string, scopeId: string | null): Promise<void> {
const db = getSqliteDb();
const now = toIso(new Date());

db.exec("BEGIN");
try {
const previous = db
.prepare(`SELECT permission_scope_id FROM sidebar_items WHERE item_id = ? AND kind = 'folder'`)
.get(folderItemId) as { permission_scope_id: string | null } | undefined;

db.prepare(`UPDATE sidebar_items SET permission_scope_id = ?, updated_at = ? WHERE item_id = ? AND kind = 'folder'`)
.run(scopeId, now, folderItemId);

const old = previous?.permission_scope_id;
if (old && old !== scopeId) dropPermissionScopeIfUnused(old);
db.exec("COMMIT");
} catch (err) {
db.exec("ROLLBACK");
throw err;
}
}

/** Not left dangling: a channel pointing at a gone scope resolves to inheriting
only by accident, and the settings dropdown shows nothing selected. */
/** Channels and folders on it go to Everyone rather than point at nothing, so
what the dropdown shows is what applies. */
export async function deletePermissionTemplate(scopeId: string): Promise<void> {
const db = getSqliteDb();
const now = toIso(new Date());
db.exec("BEGIN");
try {
db.prepare(`UPDATE channels SET permission_scope_id = NULL, updated_at = ? WHERE permission_scope_id = ?`)
db.prepare(`UPDATE channels SET permission_scope_id = NULL, follows_folder = 0, updated_at = ? WHERE permission_scope_id = ?`)
.run(now, scopeId);
db.prepare(`UPDATE sidebar_items SET permission_scope_id = NULL, updated_at = ? WHERE permission_scope_id = ?`)
.run(now, scopeId);
db.prepare(`DELETE FROM channel_permission_rules WHERE scope_id = ?`).run(scopeId);
db.prepare(`DELETE FROM channel_permission_scopes WHERE scope_id = ? AND is_system = 0`).run(scopeId);
Expand Down
83 changes: 77 additions & 6 deletions src/db/sqlite/channels.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import type { ChannelNotificationLevel, ForumTag, ServerChannelRecord, ServerSidebarItemKind, ServerSidebarItemRecord } from "../interfaces";
import {
dropPermissionScopeIfUnused,
keepScopesOfChannelsLeavingFolders,
resolveChannelScopes,
type ResolvedChannelScope,
} from "./channelScopes";
import { fromIso, getSqliteDb, intToBool, toIso } from "./connection";

function normalizeChannelType(t: unknown): "text" | "voice" {
Expand Down Expand Up @@ -69,6 +75,7 @@ function rowToChannel(r: Record<string, unknown>): ServerChannelRecord {
post_min_rank: r.post_min_rank != null ? Number(r.post_min_rank) : null,
view_min_rank: r.view_min_rank != null ? Number(r.view_min_rank) : null,
permission_scope_id: (r.permission_scope_id as string) ?? null,
follows_folder: intToBool(r.follows_folder as number),
created_at: fromIso(r.created_at as string),
updated_at: fromIso(r.updated_at as string),
};
Expand All @@ -83,6 +90,7 @@ function rowToSidebarItem(r: Record<string, unknown>): ServerSidebarItemRecord {
spacer_height: r.spacer_height != null ? Number(r.spacer_height) : null,
label: (r.label as string) ?? null,
parent_item_id: (r.parent_item_id as string) ?? null,
permission_scope_id: (r.permission_scope_id as string) ?? null,
created_at: fromIso(r.created_at as string),
updated_at: fromIso(r.updated_at as string),
};
Expand Down Expand Up @@ -215,10 +223,39 @@ export async function upsertServerSidebarItem(item: {
: null;
const parentItemId = resolveParentFolder(db, itemId, kind, item.parentItemId);

db.prepare(
`INSERT INTO sidebar_items (item_id, kind, position, channel_id, spacer_height, label, parent_item_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(item_id) DO UPDATE SET kind=?, position=?, channel_id=?, spacer_height=?, label=?, parent_item_id=?, updated_at=?`
).run(itemId, kind, position, channelId, spacerHeight, label, parentItemId, now, now, kind, position, channelId, spacerHeight, label, parentItemId, now);
const upsert = () => {
db.prepare(
`INSERT INTO sidebar_items (item_id, kind, position, channel_id, spacer_height, label, parent_item_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(item_id) DO UPDATE SET kind=?, position=?, channel_id=?, spacer_height=?, label=?, parent_item_id=?, updated_at=?`
).run(itemId, kind, position, channelId, spacerHeight, label, parentItemId, now, now, kind, position, channelId, spacerHeight, label, parentItemId, now);
// One row per channel, and the one written wins. In the same step, so nothing
// reads the channel drawn twice in between.
if (kind === "channel" && channelId) {
db.prepare(`DELETE FROM sidebar_items WHERE kind = 'channel' AND channel_id = ? AND item_id <> ?`).run(channelId, itemId);
}
};

// Only an existing row changing shape, or a second row going, can take a channel out of a folder.
const stored = db
.prepare(`SELECT kind, channel_id, parent_item_id FROM sidebar_items WHERE item_id = ?`)
.get(itemId) as { kind: string; channel_id: string | null; parent_item_id: string | null } | undefined;
const another = kind === "channel" && channelId
? db.prepare(`SELECT 1 FROM sidebar_items WHERE kind = 'channel' AND channel_id = ? AND item_id <> ?`).get(channelId, itemId)
: undefined;
const reshapes = !!another || (!!stored && (
stored.kind !== kind || (stored.channel_id ?? null) !== channelId || (stored.parent_item_id ?? null) !== parentItemId
));
if (reshapes) writeKeepingFolderScopes(upsert);
else upsert();
}

/** A new channel's first row, unless one is already down. Checked and written in
one step: the desktop sends its own row right behind the channel. */
export async function addChannelRowIfMissing(channelId: string, parentItemId: string | null): Promise<void> {
const db = getSqliteDb();
if (db.prepare(`SELECT 1 FROM sidebar_items WHERE kind = 'channel' AND channel_id = ?`).get(channelId)) return;
const { end } = db.prepare(`SELECT COALESCE(MAX(position), 0) + 10 AS end FROM sidebar_items`).get() as { end: number };
await upsertServerSidebarItem({ itemId: `sb_ch_${channelId.slice(0, 54)}`, kind: "channel", channelId, position: end, parentItemId });
}

/** A channel's sidebar entry is the only thing that puts it on screen, so
Expand All @@ -227,8 +264,42 @@ export async function deleteServerSidebarItem(itemId: string): Promise<void> {
const db = getSqliteDb();
const norm = String(itemId || "").trim().slice(0, 64);
if (!norm) return;
db.prepare(`UPDATE sidebar_items SET parent_item_id = NULL WHERE parent_item_id = ?`).run(norm);
db.prepare(`DELETE FROM sidebar_items WHERE item_id = ?`).run(norm);
const folder = db
.prepare(`SELECT permission_scope_id FROM sidebar_items WHERE item_id = ? AND kind = 'folder'`)
.get(norm) as { permission_scope_id: string | null } | undefined;

writeKeepingFolderScopes(() => {
db.prepare(`UPDATE sidebar_items SET parent_item_id = NULL WHERE parent_item_id = ?`).run(norm);
db.prepare(`DELETE FROM sidebar_items WHERE item_id = ?`).run(norm);
});
// Not before: its channels were copying from it until the write above.
if (folder?.permission_scope_id) dropPermissionScopeIfUnused(folder.permission_scope_id);
}

/** Each channel's scope as it stands, read synchronously so it can sit inside
a transaction. */
function scopesNow(): Map<string, ResolvedChannelScope> {
const db = getSqliteDb();
const channels = (db.prepare(`SELECT * FROM channels`).all() as Record<string, unknown>[]).map(rowToChannel);
const items = (db.prepare(`SELECT * FROM sidebar_items ORDER BY position ASC, item_id ASC`).all() as Record<string, unknown>[])
.map(rowToSidebarItem);
return resolveChannelScopes(channels, items);
}

/** The write and the scopes it hands out commit together, so no reader sees a
channel out of its folder and open in between. */
function writeKeepingFolderScopes(write: () => void): void {
const db = getSqliteDb();
db.exec("BEGIN");
try {
const before = scopesNow();
write();
keepScopesOfChannelsLeavingFolders(before, scopesNow());
db.exec("COMMIT");
} catch (err) {
db.exec("ROLLBACK");
throw err;
}
}

/** As {@link ensureDefaultChannels}: true when this call seeded something. */
Expand Down
20 changes: 17 additions & 3 deletions src/db/sqlite/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,10 +245,13 @@ function createSchema(d: DatabaseSync): void {
view_min_rank INTEGER,
-- Which permission scope decides what each role may do here.
--
-- NULL means the channel has no opinion: every role gets exactly what its
-- server-wide definition gives it. That is every channel until somebody
-- narrows one, so the common case stores nothing and costs nothing.
-- NULL means the channel has no scope of its own. It takes its folder's
-- while follows_folder is 1, and otherwise every role gets exactly what its
-- server-wide definition gives it. resolveChannelScopes decides which.
permission_scope_id TEXT,
-- 0 once somebody picks the channel's permissions for it. Only Everyone
-- needs it, since a scope of its own wins whatever this says.
follows_folder INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
Expand Down Expand Up @@ -303,6 +306,8 @@ function createSchema(d: DatabaseSync): void {
spacer_height INTEGER,
label TEXT,
parent_item_id TEXT,
-- A folder's scope, which its channels take unless they have their own.
permission_scope_id TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
Expand Down Expand Up @@ -593,6 +598,15 @@ function runMigrations(d: DatabaseSync): void {
d.exec("ALTER TABLE sidebar_items ADD COLUMN parent_item_id TEXT");
}

// No backfill: every folder starts with no scope, so a channel following one
// reads through to the server-wide answer it had before.
if (!hasColumn(d, "sidebar_items", "permission_scope_id")) {
d.exec("ALTER TABLE sidebar_items ADD COLUMN permission_scope_id TEXT");
}
if (!hasColumn(d, "channels", "follows_folder")) {
d.exec("ALTER TABLE channels ADD COLUMN follows_folder INTEGER NOT NULL DEFAULT 1");
}

// Older databases predate both tables. CREATE TABLE IF NOT EXISTS above only
// runs against a fresh file, so upgrading needs them here as well.
d.exec(`
Expand Down
Loading
Loading