251 lines
10 KiB
TypeScript
251 lines
10 KiB
TypeScript
// Benutzernamen (Spec 2026-10-05-aircard-benutzernamen-design.md): die Regeln für den Namen, Profile lesen (Tabelle `profiles`: eine
|
||
// öffentlich lesbare Zeile je Konto, Zeilen-ID = Konto-ID) und den eigenen Namen speichern (`POST /api/profil` des Dienstes
|
||
// `aircard-api`). Der Browser prüft nur vor; was gilt, entscheidet der Dienst: Er prüft Muster und Sperrliste noch einmal, und der
|
||
// Unique-Index auf `usernameKey` hält einen Namen auch bei zwei gleichzeitigen Anfragen nur einmal.
|
||
import { Query, type Models } from 'appwrite';
|
||
import { ID_PATTERN } from '../card/config';
|
||
import { apiPost, unavailableError } from '../community/api';
|
||
import { API_MESSAGES } from '../i18n/api-messages';
|
||
import { msg, t } from '../i18n/i18n';
|
||
import { IDS, tables } from './appwrite';
|
||
import { currentUserId } from './auth';
|
||
import { isRowMissing } from './errors';
|
||
|
||
/** Wie im Dienst (deploy/api/common.js): 3–20 Zeichen, das erste ein Buchstabe oder eine Zahl, danach Buchstaben, Zahlen, `.`, `_`, `-`. */
|
||
export const USERNAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{2,19}$/;
|
||
|
||
/**
|
||
* Gesperrte Namen als Schlüssel (klein geschrieben, Spec § 2): Rollen, der Dienst selbst und Platzhalter, die nach einem Konto aussehen.
|
||
* Genau die Liste des Dienstes (deploy/api/common.js, RESERVED_USERNAMES); profile.test.ts vergleicht beide.
|
||
*/
|
||
export const RESERVED_USERNAMES: readonly string[] = [
|
||
'admin',
|
||
'administrator',
|
||
'root',
|
||
'system',
|
||
'support',
|
||
'moderator',
|
||
'mod',
|
||
'aircard',
|
||
'aircardios',
|
||
'aircard-ios',
|
||
'aircard.ios',
|
||
'aircard_ios',
|
||
'webklar',
|
||
'anonym',
|
||
'anonymous',
|
||
'null',
|
||
'undefined',
|
||
'team',
|
||
'staff',
|
||
'official',
|
||
'offiziell',
|
||
'hilfe',
|
||
'help',
|
||
'kontakt',
|
||
'contact',
|
||
'info',
|
||
];
|
||
const RESERVED = new Set(RESERVED_USERNAMES);
|
||
|
||
/** Hinweis unter dem Feld „Benutzername“ (Registrieren und Namensdialog); angezeigt wird er mit `t(USERNAME_HINT)`. */
|
||
export const USERNAME_HINT = msg('3–20 Zeichen: Buchstaben, Zahlen, . _ -');
|
||
|
||
/** Obergrenze der Konten-IDs in einer Abfrage (so viele Werte nimmt ein `equal` sicher an). */
|
||
const LOOKUP_MAX = 100;
|
||
|
||
type TableRow = Models.Row & Record<string, unknown>;
|
||
|
||
function text(value: unknown): string {
|
||
return typeof value === 'string' ? value : '';
|
||
}
|
||
|
||
/** Der Schlüssel eines Namens (Spalte `usernameKey`): klein geschrieben. Über ihn ist ein Name nur einmal vergeben („Kenso“ = „kenso“). */
|
||
export function usernameKey(name: string): string {
|
||
return name.toLowerCase();
|
||
}
|
||
|
||
/**
|
||
* Prüft einen Namen vor dem Senden: null, wenn er passt, sonst der Text für den Nutzer in der Sprache der Seite (Muster, dann Sperrliste).
|
||
* Geprüft wird genau die Zeichenkette, die übergeben wird; wer sie vom Nutzer hat, trimmt sie vorher (der Dienst tut es nicht).
|
||
*/
|
||
export function validateUsername(name: string): string | null {
|
||
if (!USERNAME_PATTERN.test(name)) return t(API_MESSAGES.usernameInvalid);
|
||
if (RESERVED.has(usernameKey(name))) return t(API_MESSAGES.usernameReserved);
|
||
return null;
|
||
}
|
||
|
||
// ---- Namen merken und melden ------------------------------------------------------------------------------
|
||
|
||
/** Konto-ID → Benutzername, nur gefundene (ein Konto ohne Profil kann sich später einen Namen wählen, also wird es wieder gefragt). */
|
||
const names = new Map<string, string>();
|
||
const listeners = new Set<(userId: string, username: string) => void>();
|
||
|
||
/** Merkt den Namen eines Kontos, dessen Stand diese Seite gerade selbst erfahren hat (Speichern, Lesen des eigenen Profils), und meldet ihn. */
|
||
function announce(userId: string, username: string): void {
|
||
names.set(userId, username);
|
||
// Kopie, damit sich ein Hörer beim Melden abmelden darf; ein werfender Hörer stört weder die anderen noch das Ergebnis.
|
||
for (const fn of [...listeners]) {
|
||
try {
|
||
fn(userId, username);
|
||
} catch (err) {
|
||
console.error('Ein Hörer der Namensänderung ist gescheitert', err);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Meldet, wenn der Name des angemeldeten Kontos feststeht oder sich ändert: nach dem Speichern und nach dem Lesen des eigenen Profils
|
||
* (`loadMyUsername`). Nicht gemeldet wird, was `usernamesFor` über andere erfährt. Gibt die Abmelde-Funktion zurück.
|
||
*/
|
||
export function onUsernameChange(fn: (userId: string, username: string) => void): () => void {
|
||
listeners.add(fn);
|
||
return () => {
|
||
listeners.delete(fn);
|
||
};
|
||
}
|
||
|
||
/** Der Name des angemeldeten Kontos laut Zwischenspeicher; null, wenn es keinen hat, keiner angemeldet ist oder er noch nicht gelesen wurde. */
|
||
export function myUsername(): string | null {
|
||
const userId = currentUserId();
|
||
return userId ? (names.get(userId) ?? null) : null;
|
||
}
|
||
|
||
// ---- Lesen --------------------------------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Der Name eines Kontos. Hat es kein Profil, ist das Ergebnis null (Appwrite antwortet mit 404); eine ID, die keine Appwrite-ID sein
|
||
* kann, ergibt null ohne Anfrage. Andere Fehler (Netz, Server) gehen durch.
|
||
*/
|
||
export async function getUsername(userId: string): Promise<string | null> {
|
||
if (!ID_PATTERN.test(userId)) return null;
|
||
try {
|
||
const row = await tables.getRow<TableRow>({ databaseId: IDS.database, tableId: IDS.profiles, rowId: userId });
|
||
return text(row.username) || null;
|
||
} catch (err) {
|
||
if (isRowMissing(err)) return null;
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Die Namen der Konten für die Anzeige „von @name“: eine Abfrage für alle IDs, die noch nicht bekannt sind (Konten ohne Profil fehlen
|
||
* in der Map). Gefundene Namen werden gemerkt. Die Anzeige braucht den Namen nur als Beiwerk: schlägt die Abfrage fehl, ist das kein
|
||
* Fehler, es bleibt bei den schon bekannten Namen (ohne die ist die Map leer), und die Aufrufer greifen auf ihre Ersatznamen zurück.
|
||
*/
|
||
export async function usernamesFor(userIds: readonly string[]): Promise<Map<string, string>> {
|
||
const found = new Map<string, string>();
|
||
const missing: string[] = [];
|
||
for (const id of new Set(userIds)) {
|
||
if (!ID_PATTERN.test(id)) continue;
|
||
const known = names.get(id);
|
||
if (known === undefined) missing.push(id);
|
||
else found.set(id, known);
|
||
}
|
||
try {
|
||
for (let from = 0; from < missing.length; from += LOOKUP_MAX) {
|
||
const ids = missing.slice(from, from + LOOKUP_MAX);
|
||
const list = await tables.listRows<TableRow>({
|
||
databaseId: IDS.database,
|
||
tableId: IDS.profiles,
|
||
queries: [Query.equal('$id', ids), Query.limit(ids.length)],
|
||
});
|
||
for (const row of list.rows) {
|
||
const name = text(row.username);
|
||
if (name === '') continue;
|
||
// Steht für das Konto inzwischen ein Name im Speicher (es wurde während der Abfrage umbenannt), ist der neuer als die Antwort.
|
||
const current = names.get(row.$id) ?? name;
|
||
names.set(row.$id, current);
|
||
found.set(row.$id, current);
|
||
}
|
||
}
|
||
} catch {
|
||
// nur Beiwerk der Anzeige, siehe oben
|
||
}
|
||
return found;
|
||
}
|
||
|
||
/**
|
||
* Ist der Name schon an ein anderes Konto vergeben? Gefragt wird der Schlüssel (klein geschrieben), der eigene Eintrag des angemeldeten
|
||
* Kontos zählt nicht. Das ist die Vorabprüfung der Registrierung; bei zwei gleichzeitigen Anfragen entscheidet der Dienst.
|
||
* Fehler (Netz, Server) gehen durch.
|
||
*/
|
||
export async function isUsernameTaken(name: string): Promise<boolean> {
|
||
const list = await tables.listRows<TableRow>({
|
||
databaseId: IDS.database,
|
||
tableId: IDS.profiles,
|
||
queries: [Query.equal('usernameKey', usernameKey(name)), Query.limit(1)],
|
||
});
|
||
const own = currentUserId();
|
||
return list.rows.some((row) => row.$id !== own);
|
||
}
|
||
|
||
/**
|
||
* Liest den Namen des angemeldeten Kontos (nach dem Start und nach der Anmeldung), merkt und meldet ihn. Ergebnis: der Name, null ohne
|
||
* Profil oder ohne angemeldetes Konto. Lässt sich das Profil nicht lesen (Netz, Server), wirft der Aufruf: „unbekannt“ ist nicht „kein
|
||
* Name“, und nur bei „kein Name“ wird gefragt.
|
||
*/
|
||
export async function loadMyUsername(): Promise<string | null> {
|
||
const userId = currentUserId();
|
||
if (!userId) return null;
|
||
const name = await getUsername(userId);
|
||
// Kein Profil: war es inzwischen gespeichert (Registrierung: die Abfrage war schneller als das Speichern), gilt der gemerkte Name.
|
||
if (name === null) return names.get(userId) ?? null;
|
||
announce(userId, name);
|
||
return name;
|
||
}
|
||
|
||
// ---- Speichern ----------------------------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Speichert den eigenen Namen (`POST /api/profil`): legt das Profil an oder ändert es, der alte Name ist danach frei. Ergebnis ist der
|
||
* Name, wie der Dienst ihn gespeichert hat. Der Zwischenspeicher gilt sofort, und alle Hörer von `onUsernameChange` erfahren es.
|
||
* Fehler sind `ApiError`: 400 `usernameInvalid`/`usernameReserved`, 409 `usernameTaken`, sonst Anmeldung, Netz, Dienst.
|
||
*/
|
||
export async function saveUsername(name: string): Promise<string> {
|
||
const userId = currentUserId(); // das Konto, das den Aufruf stellt, auch wenn inzwischen ein anderes angemeldet ist
|
||
const res = await apiPost('/api/profil', { username: name }, { auth: true });
|
||
const saved = res.username;
|
||
// Eine Antwort ohne Namen ist keine Antwort des Dienstes (Proxy, Fehlseite): der Ausgang ist offen.
|
||
if (typeof saved !== 'string' || saved === '') throw unavailableError();
|
||
if (userId) announce(userId, saved);
|
||
return saved;
|
||
}
|
||
|
||
// ---- „In dieser Sitzung schon gefragt“ ---------------------------------------------------------------------
|
||
|
||
/** Merker in sessionStorage (Spec § 5): gilt für die Sitzung des Tabs, nicht für ein Konto. */
|
||
const PROMPTED_KEY = 'aircard.usernamePrompted';
|
||
/** Zusätzlich im Speicher: ist sessionStorage gesperrt, gilt „schon gefragt“ wenigstens bis die Seite neu geladen wird. */
|
||
let promptedInMemory = false;
|
||
|
||
/** Wurde in dieser Sitzung schon nach einem Benutzernamen gefragt? (Jeder Zugriff auf den Speicher steht in try/catch.) */
|
||
export function usernamePrompted(): boolean {
|
||
if (promptedInMemory) return true;
|
||
try {
|
||
return sessionStorage.getItem(PROMPTED_KEY) === '1';
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/** Merkt, dass in dieser Sitzung nach einem Benutzernamen gefragt wird. */
|
||
export function markUsernamePrompted(): void {
|
||
promptedInMemory = true;
|
||
try {
|
||
sessionStorage.setItem(PROMPTED_KEY, '1');
|
||
} catch {
|
||
// gesperrt oder voll: es bleibt beim Merker im Speicher
|
||
}
|
||
}
|
||
|
||
/** Nimmt den Merker zurück (die Registrierung hat ihn gesetzt, ist dann aber gescheitert). */
|
||
export function clearUsernamePrompted(): void {
|
||
promptedInMemory = false;
|
||
try {
|
||
sessionStorage.removeItem(PROMPTED_KEY);
|
||
} catch {
|
||
// gesperrt: nichts zu entfernen
|
||
}
|
||
}
|