Files
AfiS-Aktensystem/worker/src/sync.ts
T
Harlekin 5edb62bf37 Notfallzugang gilt auch im Worker
Der Voll-Abgleich hat den Administrator alle fünf Minuten hinausgeworfen:
enforceMembership kannte BOOTSTRAP_ADMIN_IDS nicht und sah nur, dass keine
seiner Rollen zur Anmeldung berechtigt. Solange das Rollen-Mapping noch leer
ist, trifft das zwangsläufig zu — der Notfallzugang war damit genau in der
Lage wirkungslos, für die es ihn gibt.

Gefunden beim ersten Lauf gegen den echten Discord-Server, nicht im Test.
2026-09-01 19:24:13 +02:00

171 lines
5.8 KiB
TypeScript

import type { Guild, GuildMember, Role } from 'discord.js'
import type { PrismaClient } from '@afis/db'
/**
* Spiegelt Guild und Mitglieder nach Postgres.
*
* Die Web-App fragt nie live bei Discord an. Dadurch ist eine Rechteänderung
* in Discord in unter einer Sekunde in der App wirksam, ohne dass ein
* einziger Request auf die Discord-API wartet. Siehe docs/02-architektur.md.
*/
/** Rollen, die Discord selbst verwaltet und die niemandem etwas nützen. */
function isRelevantRole(role: Role): boolean {
return !role.managed && role.id !== role.guild.id // @everyone hat die Guild-ID
}
/**
* Legt für jede Rolle der Guild eine Zeile in `RoleMapping` an, damit im
* Adminbereich nichts von Hand eingetragen werden muss. Vorhandene
* Zuordnungen bleiben unangetastet: gespiegelt werden nur Name und Position.
*
* Rollen, die es nicht mehr gibt, werden nicht gelöscht, sondern auf
* `isPresent = false` gesetzt. Beim Umzug auf einen anderen Discord-Server
* bleiben sie damit als Vorlage stehen. Siehe docs/13-entscheidungen.md.
*/
export async function syncRoles(db: PrismaClient, guild: Guild): Promise<number> {
const roles = [...guild.roles.cache.values()].filter(isRelevantRole)
for (const role of roles) {
await db.roleMapping.upsert({
where: { discordRoleId: role.id },
create: {
discordRoleId: role.id,
label: role.name,
sortOrder: role.position,
isPresent: true,
},
update: {
label: role.name,
sortOrder: role.position,
isPresent: true,
},
})
}
await db.roleMapping.updateMany({
where: { discordRoleId: { notIn: roles.map((r) => r.id) }, isPresent: true },
data: { isPresent: false },
})
return roles.length
}
/**
* Schreibt Rollen eines Mitglieds. Gibt zurück, ob sich etwas geändert hat,
* damit nur dann `pg_notify` ausgelöst wird — der Voll-Abgleich läuft alle
* fünf Minuten und soll nicht jedes Mal alle Caches leeren.
*
* `pg_notify` selbst kommt aus dem Trigger `user_role_notify` auf `UserRole`,
* es muss hier nichts von Hand gesendet werden.
*/
export async function syncMember(db: PrismaClient, member: GuildMember): Promise<boolean> {
const roleIds = [...member.roles.cache.keys()].filter((id) => id !== member.guild.id)
const displayName = member.nickname ?? member.user.globalName ?? member.user.username
const [existingUser, existingRoles] = await Promise.all([
db.user.findUnique({
where: { id: member.id },
select: { displayName: true, username: true, avatarHash: true, isActive: true },
}),
db.userRole.findMany({ where: { userId: member.id }, select: { discordRoleId: true } }),
])
const before = new Set(existingRoles.map((r) => r.discordRoleId))
const after = new Set(roleIds)
const rolesChanged =
before.size !== after.size || [...after].some((id) => !before.has(id))
const profileChanged =
!existingUser ||
existingUser.displayName !== displayName ||
existingUser.username !== member.user.username ||
existingUser.avatarHash !== member.user.avatar ||
!existingUser.isActive
if (!rolesChanged && !profileChanged) return false
await db.$transaction([
db.user.upsert({
where: { id: member.id },
create: {
id: member.id,
username: member.user.username,
displayName,
avatarHash: member.user.avatar,
isActive: true,
},
update: {
username: member.user.username,
displayName,
avatarHash: member.user.avatar,
isActive: true,
},
}),
db.userRole.deleteMany({ where: { userId: member.id, discordRoleId: { notIn: roleIds } } }),
...roleIds.map((discordRoleId) =>
db.userRole.upsert({
where: { userId_discordRoleId: { userId: member.id, discordRoleId } },
create: { userId: member.id, discordRoleId },
update: {},
}),
),
])
return true
}
/**
* Beendet alle Sitzungen eines Nutzers, wenn er keine Rolle mehr trägt, die
* zur Anmeldung berechtigt. Der Datensatz bleibt stehen, damit Autorenangaben
* in alten Akten gültig bleiben. Siehe docs/03-auth-und-rechte.md.
*
* Das passiert hier und nicht in der Web-App, weil es sonst erst beim nächsten
* Request des Betroffenen griffe — und genau darauf kann man sich nicht
* verlassen, wenn jemandem gerade der Zugriff entzogen wurde.
*/
export async function enforceMembership(
db: PrismaClient,
userId: string,
bootstrapAdminIds: readonly string[] = [],
): Promise<boolean> {
// Der Notfallzugang gilt auch hier. Sonst wirft der Voll-Abgleich den
// Administrator alle fünf Minuten hinaus, solange noch keine Rolle zur
// Anmeldung berechtigt — und einrichten kann er es dann nie.
if (bootstrapAdminIds.includes(userId)) return false
const roles = await db.userRole.findMany({
where: { userId },
select: { discordRoleId: true },
})
const granting = await db.roleMapping.count({
where: {
discordRoleId: { in: roles.map((r) => r.discordRoleId) },
grantsMembership: true,
},
})
if (granting > 0) return false
const [{ count }] = await Promise.all([
db.session.deleteMany({ where: { userId } }),
db.user.update({ where: { id: userId }, data: { isActive: false } }).catch(() => undefined),
])
if (count > 0) {
console.log(`[worker] ${count} Sitzung(en) von ${userId} beendet, keine AfiS-Rolle mehr`)
}
return true
}
/** Serveraustritt: Sitzungen beenden, Datensatz behalten. */
export async function handleDeparture(db: PrismaClient, userId: string): Promise<void> {
await db.$transaction([
db.session.deleteMany({ where: { userId } }),
db.userRole.deleteMany({ where: { userId } }),
])
await db.user.update({ where: { id: userId }, data: { isActive: false } }).catch(() => undefined)
console.log(`[worker] ${userId} hat den Server verlassen, Sitzungen beendet`)
}