Skip to content

Commit

Permalink
feat: keep track of "initial usernames" for users: a permanent unique…
Browse files Browse the repository at this point in the history
… ID that lasts across handle changes.
  • Loading branch information
zicklag committed Dec 12, 2024
1 parent b0afcd8 commit a4134f3
Show file tree
Hide file tree
Showing 6 changed files with 126 additions and 18 deletions.
18 changes: 5 additions & 13 deletions src/lib/leaf/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { leafClient, subspace_link } from '.';

import type { ExactLink, IntoPathSegment, Unit } from 'leaf-proto';
import { env } from '$env/dynamic/public';
import { genRandomUsernameSuffix, validUnsubscribedUsernameRegex } from '$lib/usernames/client';
import { validUnsubscribedUsernameRegex } from '$lib/usernames/client';

/** A "complete" profile loaded from multiple components. */
export interface Profile {
Expand Down Expand Up @@ -336,7 +336,7 @@ export async function getProfiles(): Promise<
* Apply any changes necessary to the user's data since they have been unsubscribed.
*
* This may include changing their handle because they are no longer allowed to use their custom
* domain or memorable username.
* domain or non-number-suffixed username.
*
* @param rauthyId The user's rauthy ID
*/
Expand All @@ -350,19 +350,11 @@ export async function unsubscribeUser(rauthyId: string) {
if (prefix.match(validUnsubscribedUsernameRegex)) {
// Nothing to do if their username is already valid for an unsubscribed user.
return;
} else {
const newUsername = prefix + genRandomUsernameSuffix();
await usernames.claim({ username: newUsername }, rauthyId);
}
} else {
await usernames.unset(currentUsername);
const newUsername = currentUsername.replace(/[^a-zA-Z0-9]/g, '-') + genRandomUsernameSuffix();
try {
await usernames.claim({ username: newUsername }, rauthyId);
} catch (_e) {
// If this doesn't work, we'll just let the user claim a new name when they sign up.
}
}

// Revert the user back to their initial username
await usernames.setUsernameToInitialUsername(rauthyId);
}

/**
Expand Down
3 changes: 2 additions & 1 deletion src/lib/link_verifier/LinkVerifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { GitHubLinkVerificationStrategy } from './strategy/GitHubLinkVerificatio

import type { WebLink } from '$lib/leaf/profile';
import type { LinkVerificationStrategyFactory } from './strategy/LinkVerificationStrategy';
import { env } from '$env/dynamic/public';

export const VERIFIABLE_ORIGIN_STRATEGY: Record<string, LinkVerificationStrategyFactory> = {
'https://github.com': (dom) => new GitHubLinkVerificationStrategy(dom)
Expand Down Expand Up @@ -75,6 +76,6 @@ export class LinkVerifier {
}

private userProfileLink(): string {
return `https://a.weird.one/${this.userName}`;
return `${env.PUBLIC_URL}/${this.userName}`;
}
}
102 changes: 100 additions & 2 deletions src/lib/usernames/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@ import { leafClient } from '../leaf';
import { env } from '$env/dynamic/public';
import { resolveAuthoritative } from '../dns/resolve';
import { APP_IPS } from '../dns/server';
import { validDomainRegex, validUsernameRegex, validUnsubscribedUsernameRegex } from './client';
import {
validDomainRegex,
validUsernameRegex,
validUnsubscribedUsernameRegex,
genRandomUsernameSuffix
} from './client';
import { dev } from '$app/environment';

const USER_NAMES_PREFIX = 'weird:users:names:';
Expand Down Expand Up @@ -111,6 +116,28 @@ with value "${expectedValue}". Found other values: ${txtRecords.map((v) => `"${v
const TRIES = 3;
let failures = 0;
while (failures <= TRIES) {
let initialUsername: string = '';
let initialUsernameKey: undefined | string;
if ('username' in input) {
const existingInitialUsername = await redis.hGet(rauthyIdKey, 'initialUsername');
if (!existingInitialUsername) {
initialUsername = input.username;
if (!initialUsername.match(validUnsubscribedUsernameRegex)) {
initialUsername += genRandomUsernameSuffix();
}

initialUsername += '.' + env.PUBLIC_USER_DOMAIN_PARENT;

initialUsernameKey = USER_NAMES_PREFIX + initialUsername;
redis.watch([initialUsernameKey]);

if (await redis.exists(initialUsernameKey)) {
await redis.unwatch();
throw `Cannot claim initial username "${initialUsername}": username already claimed.`;
}
}
}

redis.watch([usernameKey, rauthyIdKey, subspaceKey]);

if (await redis.exists(usernameKey)) {
Expand All @@ -125,6 +152,12 @@ with value "${expectedValue}". Found other values: ${txtRecords.map((v) => `"${v
multi.hSet(rauthyIdKey, 'username', username);
multi.hSet(subspaceKey, 'username', username);

if (initialUsername && initialUsernameKey) {
multi.hSet(initialUsernameKey, 'subspace', subspace);
multi.hSet(initialUsernameKey, 'rauthyId', rauthyId);
multi.hSet(rauthyIdKey, 'initialUsername', initialUsername);
}

try {
await multi.exec();

Expand All @@ -151,6 +184,13 @@ async function unset(username: string) {

const user = await redis.hGetAll(usernameKey);

const initialUsername = await getInitialUsername(user.rauthyId);
if (initialUsername == username) {
await redis.unwatch();
// Initial usernames are never unset.
return;
}

const subspaceKey = USER_SUBSPACES_PREFIX + user.subspace;
const rauthyIdKey = USER_RAUTHY_IDS_PREFIX + user.rauthyId;
await redis.watch([subspaceKey, rauthyIdKey]);
Expand All @@ -170,6 +210,7 @@ async function unset(username: string) {

async function* list(): AsyncGenerator<{
username?: string;
initialUsername?: string;
rauthyId: string;
subspace: Uint8Array;
}> {
Expand All @@ -178,6 +219,7 @@ async function* list(): AsyncGenerator<{
const rauthyId = segments[segments.length - 1];
yield {
rauthyId,
initialUsername: await getInitialUsername(rauthyId),
username: await getByRauthyId(rauthyId),
subspace: await subspaceByRauthyId(rauthyId)
};
Expand Down Expand Up @@ -217,6 +259,59 @@ async function getBySubspace(
return { username, rauthyId };
}

async function getInitialUsername(rauthyId: string): Promise<string | undefined> {
return await redis.hGet(USER_RAUTHY_IDS_PREFIX + rauthyId, 'initialUsername');
}

/**
* Helper function to generate initial usernames for all users without them.
*
* This is just used by the admin interface as a way to handle the fact that we didn't originally
* have a concept of initial usernames and existing users will need one generated.
*/
async function generateInitialUsernamesForAllUsers() {
for await (const user of list()) {
if (!user.initialUsername && user.username) {
let initialUsername;
if (user.username.endsWith('.' + env.PUBLIC_USER_DOMAIN_PARENT)) {
const shortName = user.username.split('.' + env.PUBLIC_USER_DOMAIN_PARENT)[0];
initialUsername = shortName + genRandomUsernameSuffix();
} else {
initialUsername = user.username.replace(/[^a-zA-Z0-9]/g, '-') + genRandomUsernameSuffix();
}
initialUsername += '.' + env.PUBLIC_USER_DOMAIN_PARENT;

const initialUsernameKey = USER_NAMES_PREFIX + initialUsername;
redis.watch([initialUsernameKey]);
if (await redis.exists(initialUsernameKey)) {
throw "Initial username already exists, try again. That's very unlucky! Try again.";
}

const multi = redis.multi();
multi.hSet(initialUsernameKey, 'rauthyId', user.rauthyId);
multi.hSet(initialUsernameKey, 'subspace', base32Encode(user.subspace));
multi.hSet(USER_RAUTHY_IDS_PREFIX + user.rauthyId, 'initialUsername', initialUsername);
await multi.exec();
}
}
}

/**
* Sets a user's username to their initial username, freeing whatever their current username is.
*
* If they do not have an initial username, their username will just be unset.
*/
async function setUsernameToInitialUsername(rauthyId: string) {
const initialUsername = await getInitialUsername(rauthyId);
const username = await getByRauthyId(rauthyId);
if (username) {
await unset(username);
}
if (initialUsername) {
await redis.hSet(USER_RAUTHY_IDS_PREFIX + rauthyId, 'username', initialUsername);
}
}

export const usernames = {
validDomainRegex,
validUsernameRegex,
Expand All @@ -229,5 +324,8 @@ export const usernames = {
subspaceByRauthyId,
getSubspace,
getRauthyId,
getBySubspace
getBySubspace,
getInitialUsername,
generateInitialUsernamesForAllUsers,
setUsernameToInitialUsername
};
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@
<code class="font-mono">CNAME</code>
</td>
<td> <pre>@</pre> </td>
<td> <pre>a.weird.one</pre> </td>
<td> <pre>{env.PUBLIC_DOMAIN}</pre> </td>
</tr>
<tr>
<td>
Expand Down Expand Up @@ -235,7 +235,7 @@
<code class="font-mono">CNAME</code>
</td>
<td> <pre>{domain.split('.').slice(0, -2).join('.')}</pre> </td>
<td> <pre>a.weird.one</pre> </td>
<td> <pre>{env.PUBLIC_DOMAIN}</pre> </td>
</tr>
<tr>
<td>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,5 +35,14 @@ export const actions = {
}

return { success: `Username ${username} deleted.` };
},
generateInitialUsernames: async () => {
try {
await usernames.generateInitialUsernamesForAllUsers();
} catch (e) {
return { error: `Error generating initial usernames: ${e}` };
}

return { success: `Initial usernames generated.` };
}
} satisfies Actions;
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,19 @@
</fieldset>
</form>

<h2>Generate Initial Usernames for All Users</h2>

<form method="post" action="?/generateInitialUsernames">
<button>Generate</button>
</form>

<h2>Users</h2>

<table>
<thead>
<tr>
<td>Username</td>
<td>Initial Username</td>
<td>Rauthy ID</td>
<td>Subspace</td>
</tr>
Expand All @@ -54,6 +61,7 @@
{user.username || '[not set]'}
</a>
</td>
<td>{user.initialUsername}</td>
<td>{user.rauthyId}</td>
<td>{base32Encode(user.subspace)}</td>
</tr>
Expand Down

0 comments on commit a4134f3

Please sign in to comment.