Skip to content

Commit

Permalink
feat: allow you to choose from multiple public suffixes.
Browse files Browse the repository at this point in the history
  • Loading branch information
zicklag committed Dec 21, 2024
1 parent 63bdec6 commit 89b12ad
Show file tree
Hide file tree
Showing 20 changed files with 174 additions and 137 deletions.
2 changes: 1 addition & 1 deletion .env.local
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ FEEDBACK_WEBHOOK=""
PUBLIC_URL=http://localhost:9523
PUBLIC_DOMAIN=localhost:9523
DNS_PORT=7753
PUBLIC_USER_DOMAIN_PARENT=user.localhost:9523
PUBLIC_SUFFIXES=user.localhost:9523,user2.localhost:9523,awesome.localhost:9523
REDIS_URL="redis://localhost:7634"

PUBLIC_ENABLE_EXPERIMENTS=true
Expand Down
27 changes: 8 additions & 19 deletions src/lib/dns/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { z } from 'zod';
import { RCode } from 'dinodns/common/core/utils';
import { redis } from '$lib/redis';
import { serverGlobals } from '$lib/server-globals';
import { usernames } from '$lib/usernames';

const REDIS_USER_PREFIX = 'weird:users:names:';
const REDIS_DNS_RECORD_PREFIX = 'weird:dns:records:';
Expand All @@ -26,16 +27,6 @@ const redisDnsRecordSchema = z.array(
})
);

/** Helper function to escape a string so we can put it literally into a regex without some
* of it's characters being interpreted as regex special characters. */
const escapeStringForEmbeddingInRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');

const WEIRD_HOST_TXT_RECORD_REGEX = new RegExp(
`^_weird\\.([^\\.]*)\\.${escapeStringForEmbeddingInRegex(pubenv.PUBLIC_USER_DOMAIN_PARENT.split(':')[0])}$`
);
const WEIRD_HOST_A_RECORD_REGEX = new RegExp(
`^([^\\.]*)\\.${escapeStringForEmbeddingInRegex(pubenv.PUBLIC_USER_DOMAIN_PARENT.split(':')[0])}$`
);
const VALID_DOMAIN_REGEX =
/(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]/i;

Expand Down Expand Up @@ -320,10 +311,14 @@ export async function startDnsServer() {
res.packet.flags = res.packet.flags | AUTHORITATIVE_ANSWER;
ret(v);
};
const { type, name } = question;
let { type, name } = question;
name = name.toLowerCase();
const suffix = usernames.publicSuffix(name);
switch (type) {
case 'TXT':
const txtUsername = name.toLowerCase().match(WEIRD_HOST_TXT_RECORD_REGEX)?.[1];
if (!name.startsWith('_weird.')) return returnAnswers(null);
if (!suffix) return returnAnswers(null);
const txtUsername = name.split('_weird.')[1];
if (!txtUsername) return returnAnswers(null);
const pubkey = await redis.hGet(REDIS_USER_PREFIX + txtUsername, 'subspace');
if (!pubkey) return returnAnswers(null);
Expand All @@ -344,13 +339,7 @@ export async function startDnsServer() {
]);
break;
case 'A':
const aUsername = name.toLowerCase().match(WEIRD_HOST_A_RECORD_REGEX)?.[1];
if (!aUsername) return returnAnswers(null);

// TODO: eventually we only want to return records for users that exist
// const exists = await redis.exists(REDIS_USER_PREFIX + aUsername);
// if (!exists) return returnAnswers(null);

if (!suffix) return returnAnswers(null);
returnAnswers(
APP_IPS.map((ip) => ({
name,
Expand Down
8 changes: 3 additions & 5 deletions src/lib/leaf/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@ import { resolveUserSubspaceFromDNS } from '$lib/dns/resolve';
import { leafClient, subspace_link } from '.';

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

/** A "complete" profile loaded from multiple components. */
Expand Down Expand Up @@ -346,10 +344,10 @@ export async function applyProfileBenefits(rauthyId: string, benefits: Set<Benef
const currentUsername = await usernames.getByRauthyId(rauthyId);
if (!currentUsername) return;

if (currentUsername.endsWith(env.PUBLIC_USER_DOMAIN_PARENT)) {
const split = usernames.splitPublicSuffix(currentUsername);
if (split) {
if (!benefits.has('non_numbered_username')) {
const prefix = currentUsername.split('.' + env.PUBLIC_USER_DOMAIN_PARENT)[0];
if (prefix.match(validUnsubscribedUsernameRegex)) {
if (split.prefix.match(usernames.validUnsubscribedUsernameRegex)) {
// Nothing to do if their username is already valid for an unsubscribed user.
return;
} else {
Expand Down
3 changes: 2 additions & 1 deletion src/lib/themes/minimal.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<script lang="ts">
import { page } from '$app/stores';
import { env } from '$env/dynamic/public';
import { usernames } from '$lib/usernames/client';
import AvatarEditor from '$lib/components/avatar/editor.svelte';
import EditLinks from '$lib/components/pubpage-admin/edit-links.svelte';
import type { Profile } from '$lib/leaf/profile';
Expand Down Expand Up @@ -123,7 +124,7 @@
<h1 style="margin-top: 1em;">{profile.display_name}</h1>
{/if}
<span>
{$page.url.host.split('.' + env.PUBLIC_USER_DOMAIN_PARENT)[0]}
{usernames.shortNameOrDomain($page.url.host)}
</span>

<div class="links">
Expand Down
65 changes: 59 additions & 6 deletions src/lib/usernames/client.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,61 @@
export const validUsernameRegex = /^([a-z0-9][_-]?){3,32}$/;
export const validUnsubscribedUsernameRegex = /^([a-z0-9][_-]?){3,32}[0-9]{4}$/;
/**
* Username related functionality that can be imported safely into client side code ( but may be
* useful even on the server ).
*/

export const validDomainRegex = /^([A-Za-z0-9-]{1,63}\.)+[A-Za-z]{2,12}(:[0-9]{1,5})?$/;
import { env } from '$env/dynamic/public';

export function genRandomUsernameSuffix() {
return Math.floor(Math.random() * (9999 - 1000 + 1) + 1000).toString();
}
export const usernames = {
validUsernameRegex: /^([a-z0-9][_-]?){3,32}$/,
validUnsubscribedUsernameRegex: /^([a-z0-9][_-]?){3,32}[0-9]{4}$/,

validDomainRegex: /^([A-Za-z0-9-]{1,63}\.)+[A-Za-z]{2,12}(:[0-9]{1,5})?$/,

genRandomUsernameSuffix() {
return Math.floor(Math.random() * (9999 - 1000 + 1) + 1000).toString();
},

/** Get the list of configured public suffixes for this Weird server */
publicSuffixes(): string[] {
return env.PUBLIC_SUFFIXES.split(',');
},

/** Get the public suffix of the given user handle / domain. Returns `undefined` if it does not end
* in one of the Weird server's configured public suffixes. */
publicSuffix(domain: string): string | undefined {
return env.PUBLIC_SUFFIXES.split(',').find((x) => domain.endsWith(x));
},

/** Returns the domain split into the prefix and public suffix, or `undefined` if it didn't end
* with one of the Weird server's configured public suffixes. */
splitPublicSuffix(domain: string): { prefix: string; suffix: string } | undefined {
const suffix = this.publicSuffix(domain);
if (!suffix) return;
const prefix = domain.split(suffix)[0];
return { prefix, suffix };
},

/** Get the default public suffix. */
defaultSuffix(): string {
return env.PUBLIC_SUFFIXES.split(',')[0];
},

/** Get the short name of the domain / handle, if it is suffixed by the default public suffix,
* otherwise return the full domain. */
shortNameOrDomain(domain: string): string {
return domain.split('.' + this.defaultSuffix())[0];
},

/** Takes an argument that is either a short name, or a full domain, and returns the short name
* extended with the default suffix, or the existing domain unchanged. */
fullDomain(shortNameOrDomain: string): string {
return shortNameOrDomain.includes('.')
? shortNameOrDomain
: `${shortNameOrDomain}.${this.defaultSuffix()}`;
},

/** Returns true if the given suffix is one of the Weird server's configured public suffixes. */
isPublicSuffix(suffix: string): boolean {
return env.PUBLIC_SUFFIXES.split(',').some((x) => x == suffix);
}
};
39 changes: 18 additions & 21 deletions src/lib/usernames/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,8 @@ import { base32Decode, base32Encode, type SubspaceId } from 'leaf-proto';
import { leafClient } from '../leaf';
import { env } from '$env/dynamic/public';
import { resolveAuthoritative } from '../dns/resolve';
import { usernames as usernamesClient } from "./client"
import { APP_IPS } from '../dns/server';
import {
validDomainRegex,
validUsernameRegex,
validUnsubscribedUsernameRegex,
genRandomUsernameSuffix
} from './client';
import { dev } from '$app/environment';
import { CronJob } from 'cron';

Expand All @@ -24,7 +19,7 @@ async function setSubspace(rauthyId: string, subspace: SubspaceId) {
}

async function claim(
input: { username: string } | { domain: string; skipDomainCheck?: boolean },
input: { username: string; suffix: string } | { domain: string; skipDomainCheck?: boolean },
rauthyId: string
) {
const oldUsername = await getByRauthyId(rauthyId);
Expand All @@ -37,11 +32,14 @@ async function claim(
if ('username' in input) {
// Claiming a local username

if (!input.username.match(validUsernameRegex)) {
if (!input.username.match(usernamesClient.validUsernameRegex)) {
throw `Username does not pass valid username check: '${input.username}'`;
} else {
username = input.username + '.' + env.PUBLIC_USER_DOMAIN_PARENT;
}
if (!usernames.isPublicSuffix(input.suffix)) {
throw `Suffix ${input.suffix} not registered as a public suffix for the Weird server.`;
}

username = input.username + '.' + input.suffix;
} else {
// Claim a custom domain
const isApex = input.domain.split('.').length == 2;
Expand Down Expand Up @@ -123,11 +121,11 @@ with value "${expectedValue}". Found other values: ${txtRecords.map((v) => `"${v
const existingInitialUsername = await redis.hGet(rauthyIdKey, 'initialUsername');
if (!existingInitialUsername) {
initialUsername = input.username;
if (!initialUsername.match(validUnsubscribedUsernameRegex)) {
initialUsername += genRandomUsernameSuffix();
if (!initialUsername.match(usernamesClient.validUnsubscribedUsernameRegex)) {
initialUsername += usernames.genRandomUsernameSuffix();
}

initialUsername += '.' + env.PUBLIC_USER_DOMAIN_PARENT;
initialUsername += '.' + input.suffix;

initialUsernameKey = USER_NAMES_PREFIX + initialUsername;
redis.watch([initialUsernameKey]);
Expand Down Expand Up @@ -277,13 +275,14 @@ 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();
const split = usernamesClient.splitPublicSuffix(user.username);
if (split) {
initialUsername = split.prefix + usernamesClient.genRandomUsernameSuffix();
} else {
initialUsername = user.username.replace(/[^a-zA-Z0-9]/g, '-') + genRandomUsernameSuffix();
initialUsername =
user.username.replace(/[^a-zA-Z0-9]/g, '-') + usernamesClient.genRandomUsernameSuffix();
}
initialUsername += '.' + env.PUBLIC_USER_DOMAIN_PARENT;
initialUsername += '.' + usernamesClient.defaultSuffix();

const initialUsernameKey = USER_NAMES_PREFIX + initialUsername;
redis.watch([initialUsernameKey]);
Expand Down Expand Up @@ -364,9 +363,7 @@ function cronJob(): CronJob {
}

export const usernames = {
validDomainRegex,
validUsernameRegex,
validUnsubscribedUsernameRegex,
...usernamesClient,
setSubspace,
claim,
unset,
Expand Down
10 changes: 4 additions & 6 deletions src/routes/(app)/[username]/+layout.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,17 @@ import type { LayoutServerLoad } from '../$types';
import { getSession } from '$lib/rauthy/server';
import { leafClient, subspace_link } from '$lib/leaf';
import { Name } from 'leaf-proto/components';
import { env } from '$env/dynamic/public';
import { usernames } from '$lib/usernames/index';
import { base32Encode } from 'leaf-proto';
import { billing, type UserSubscriptionInfo } from '$lib/billing';

export const load: LayoutServerLoad = async ({ fetch, params, request }) => {
if (params.username?.endsWith('.' + env.PUBLIC_USER_DOMAIN_PARENT)) {
return redirect(302, `/${params.username.split('.' + env.PUBLIC_USER_DOMAIN_PARENT)[0]}`);
const username = usernames.shortNameOrDomain(params.username!);
if (username != params.username) {
return redirect(302, `/${username}`);
}

const fullUsername = params.username!.includes('.')
? params.username!
: `${params.username}.${env.PUBLIC_USER_DOMAIN_PARENT}`;
const fullUsername = usernames.fullDomain(params.username!);

let profileMatchesUserSession = false;

Expand Down
5 changes: 3 additions & 2 deletions src/routes/(app)/[username]/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import FeaturedSocialMediaButton from '$lib/components/social-media/featured-social-media-button.svelte';
import PostCard from './post-card.svelte';
import { getFeaturedSocialMediaDetails } from '$lib/utils/social-links';
import { usernames } from '$lib/usernames/client';
let { data, form }: { data: PageData; form: ActionData } = $props();
Expand Down Expand Up @@ -117,7 +118,7 @@
if (!editingState.profile.bio) editingState.profile.bio = '';
if (!editingState.profile.bio)
editingState.profile.display_name =
data.profile.display_name || data.username.split('.' + env.PUBLIC_USER_DOMAIN_PARENT)[0];
data.profile.display_name || usernames.shortNameOrDomain(data.username);
editingState.editing = true;
editingTagsState = data.profile.tags.join(', ');
Expand Down Expand Up @@ -176,7 +177,7 @@
<h1 class="relative grid text-4xl">
{#if !editingState.editing}
<div style="grid-area: 1 / 1;">
{profile.display_name || data.username.split('.' + env.PUBLIC_USER_DOMAIN_PARENT)[0]}
{profile.display_name || usernames.shortNameOrDomain(data.username)}
</div>
{:else}
<div style="grid-area: 1 / 1;">
Expand Down
10 changes: 3 additions & 7 deletions src/routes/(app)/[username]/[slug]/+page.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,11 @@ import { dateToUnixTimestamp } from '$lib/utils/time';
import { usernames } from '$lib/usernames/index';

export const load: PageServerLoad = async ({ params }): Promise<{ page: Page }> => {
const username = params.username.split('.' + env.PUBLIC_USER_DOMAIN_PARENT)[0];
const username = usernames.shortNameOrDomain(params.username);
if (username != params.username) {
return redirect(302, `/${username}/${params.slug}`);
}
const fullUsername = params.username!.includes('.')
? params.username!
: `${params.username}.${env.PUBLIC_USER_DOMAIN_PARENT}`;
const fullUsername = usernames.fullDomain(username);

const subspace = await usernames.getSubspace(fullUsername);
if (!subspace) return error(404, `User not found: ${fullUsername}`);
Expand Down Expand Up @@ -48,9 +46,7 @@ export const load: PageServerLoad = async ({ params }): Promise<{ page: Page }>

export const actions = {
default: async ({ request, params, url, fetch }) => {
const fullUsername = params.username!.includes('.')
? params.username!
: `${params.username}.${env.PUBLIC_USER_DOMAIN_PARENT}`;
const fullUsername = usernames.fullDomain(params.username);
const subspace = await usernames.getSubspace(fullUsername);
if (!subspace) return error(404, `User not found: ${fullUsername}`);

Expand Down
7 changes: 2 additions & 5 deletions src/routes/(app)/[username]/[slug]/revisions/+page.server.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,16 @@
import type { PageServerLoad } from './$types';
import { error, redirect } from '@sveltejs/kit';
import { env } from '$env/dynamic/public';
import { leafClient, subspace_link } from '$lib/leaf';
import { usernames } from '$lib/usernames/index';

export const load: PageServerLoad = async ({
params
}): Promise<{ revisions: (number | bigint)[] }> => {
const username = params.username.split('.' + env.PUBLIC_USER_DOMAIN_PARENT)[0];
const username = usernames.shortNameOrDomain(params.username);
if (username != params.username) {
return redirect(302, `/${username}/${params.slug}`);
}
const fullUsername = params.username!.includes('.')
? params.username!
: `${params.username}.${env.PUBLIC_USER_DOMAIN_PARENT}`;
const fullUsername = usernames.fullDomain(username);
const subspace = await usernames.getSubspace(fullUsername);
if (!subspace) return error(404, `User not found: ${fullUsername}`);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,6 @@
import type { Actions, PageServerLoad } from './$types';
import {
WebLinks,
WeirdWikiPage,
WeirdWikiRevisionAuthor,
appendSubpath,
getProfileById,
profileLinkById,
profileLinkByUsername
} from '$lib/leaf/profile';
import { error, fail, redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { WebLinks, WeirdWikiRevisionAuthor } from '$lib/leaf/profile';
import { error, redirect } from '@sveltejs/kit';
import { env } from '$env/dynamic/public';
import { leafClient, subspace_link } from '$lib/leaf';
import { CommonMark, Name } from 'leaf-proto/components';
Expand All @@ -18,13 +10,11 @@ import { usernames } from '$lib/usernames/index';
export const load: PageServerLoad = async ({
params
}): Promise<{ page: Page; revisionAuthor: string }> => {
const username = params.username.split('.' + env.PUBLIC_USER_DOMAIN_PARENT)[0];
const username = usernames.shortNameOrDomain(params.username);
if (username != params.username) {
return redirect(302, `/${username}/${params.slug}`);
}
const fullUsername = params.username!.includes('.')
? params.username!
: `${params.username}.${env.PUBLIC_USER_DOMAIN_PARENT}`;
const fullUsername = usernames.fullDomain(username);

const subspace = await usernames.getSubspace(fullUsername);
if (!subspace) return error(404, `User not found: ${fullUsername}`);
Expand Down
Loading

0 comments on commit 89b12ad

Please sign in to comment.