Skip to content

Commit

Permalink
feat: require subscriptions for handles without numbers and for custo…
Browse files Browse the repository at this point in the history
…m domains.
  • Loading branch information
zicklag committed Dec 11, 2024
1 parent 9029470 commit 2534b41
Show file tree
Hide file tree
Showing 19 changed files with 311 additions and 55 deletions.
12 changes: 12 additions & 0 deletions src/lib/billing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,18 @@ class BillingEngine {
await this.#updateSubscriptionInfo(rauthyId, data);
}
}

async grantFreeTrial(rauthyId: string, expires: Date) {
await redis.set(REDIS_FREE_TRIALS_PREFIX + rauthyId, expires.getTime().toString());
}

async cancelFreeTrial(rauthyId: string) {
await redis.del(REDIS_FREE_TRIALS_PREFIX + rauthyId);
const subscriptionInfo = this.getSubscriptionInfo(rauthyId);
if (!(await subscriptionInfo).isSubscribed) {
await unsubscribeUser(rauthyId);
}
}
}

export const billing = new BillingEngine();
25 changes: 24 additions & 1 deletion src/lib/leaf/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ 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 { genRandomUsernameSuffix, validUnsubscribedUsernameRegex } from '$lib/usernames/client';

/** A "complete" profile loaded from multiple components. */
export interface Profile {
Expand Down Expand Up @@ -339,7 +341,28 @@ export async function getProfiles(): Promise<
* @param rauthyId The user's rauthy ID
*/
export async function unsubscribeUser(rauthyId: string) {

// When a user loses their subscription, we have to reset their username to a free one.
const currentUsername = await usernames.getByRauthyId(rauthyId);
if (!currentUsername) return;

if (currentUsername.endsWith(env.PUBLIC_USER_DOMAIN_PARENT)) {
const prefix = currentUsername.split('.' + env.PUBLIC_USER_DOMAIN_PARENT)[0];
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.
}
}
}

/**
Expand Down
5 changes: 5 additions & 0 deletions src/lib/usernames/client.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
export const validUsernameRegex = /^([a-z0-9][_-]?){3,32}$/;
export const validUnsubscribedUsernameRegex = /^([a-z0-9][_-]?){3,32}[0-9]{4}$/;

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

export function genRandomUsernameSuffix() {
return Math.floor(Math.random() * (9999 - 1000 + 1) + 1000).toString();
}
9 changes: 8 additions & 1 deletion src/lib/usernames/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { leafClient } from '../leaf';
import { env } from '$env/dynamic/public';
import { resolveAuthoritative } from '../dns/resolve';
import { APP_IPS } from '../dns/server';
import { validDomainRegex, validUsernameRegex } from './client';
import { validDomainRegex, validUsernameRegex, validUnsubscribedUsernameRegex } from './client';
import { dev } from '$app/environment';

const USER_NAMES_PREFIX = 'weird:users:names:';
Expand All @@ -21,6 +21,7 @@ async function claim(
input: { username: string } | { domain: string; skipDomainCheck?: boolean },
rauthyId: string
) {
const oldUsername = await getByRauthyId(rauthyId);
const rauthyIdKey = USER_RAUTHY_IDS_PREFIX + rauthyId;

const subspace = base32Encode(await subspaceByRauthyId(rauthyId));
Expand Down Expand Up @@ -126,6 +127,11 @@ with value "${expectedValue}". Found other values: ${txtRecords.map((v) => `"${v

try {
await multi.exec();

if (oldUsername) {
await unset(oldUsername);
}

return;
} catch (e) {
failures += 1;
Expand Down Expand Up @@ -214,6 +220,7 @@ async function getBySubspace(
export const usernames = {
validDomainRegex,
validUsernameRegex,
validUnsubscribedUsernameRegex,
setSubspace,
claim,
unset,
Expand Down
1 change: 1 addition & 0 deletions src/routes/(app)/[username]/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
type: 'component',
component: { ref: SetHandleModal },
subspace: data.subspace,
subscriptionInfo: data.subscriptionInfo,
async response(r) {
if ('error' in r) {
error = r.error;
Expand Down
49 changes: 44 additions & 5 deletions src/routes/(app)/[username]/components/ChangeHandleModal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -4,32 +4,53 @@
// Stores
import { getModalStore, ProgressRadial, Tab, TabGroup } from '@skeletonlabs/skeleton';
import { env } from '$env/dynamic/public';
import { validDomainRegex, validUsernameRegex } from '$lib/usernames/client';
import {
genRandomUsernameSuffix,
validDomainRegex,
validUsernameRegex
} from '$lib/usernames/client';
import { goto } from '$app/navigation';
import type { UserSubscriptionInfo } from '$lib/billing';
import Icon from '@iconify/svelte';
// Props
/** Exposes parent props to this component. */
const { parent }: { parent: SvelteComponent } = $props();
const modalStore = getModalStore();
let subscriptionInfo = $state(undefined) as UserSubscriptionInfo | undefined;
$effect(() => {
subscriptionInfo = ($modalStore[0] as any).subscriptionInfo;
});
let selectedTab = $state(0);
let handle = $state('');
let domain = $state('');
let error = $state(null) as null | string;
let verifying = $state(false);
let valid = $derived(
selectedTab == 0 ? !!handle.match(validUsernameRegex) : !!domain.match(validDomainRegex)
selectedTab == 0
? !!handle.match(validUsernameRegex)
: !!domain.match(validDomainRegex) && subscriptionInfo?.isSubscribed == true
);
let randomNumberSuffix = $state(genRandomUsernameSuffix());
let fullUsernameSuffix = $derived(
(subscriptionInfo?.isSubscribed ? '' : randomNumberSuffix) + '.' + env.PUBLIC_USER_DOMAIN_PARENT
);
let handleWithSuffix = $derived(
handle + (subscriptionInfo?.isSubscribed ? '' : randomNumberSuffix)
);
// We've created a custom submit function to pass the response and close the modal.
async function onFormSubmit(e: SubmitEvent) {
e.preventDefault();
if (selectedTab == 0) {
console.log(handleWithSuffix);
const resp = await fetch(`/${handle}/settings/handle`, {
method: 'post',
body: JSON.stringify({ username: handle }),
body: JSON.stringify({ username: handleWithSuffix }),
headers: [['content-type', 'application/json']]
});
Expand Down Expand Up @@ -101,18 +122,28 @@
<svelte:fragment slot="panel">
<div class="p-2">
{#if selectedTab === 0}
{#if !subscriptionInfo?.isSubscribed}
<p class="text- flex items-center gap-3 pb-4 text-secondary-200">
<Icon icon="material-symbols:error-outline" font-size={40} /> Your handle will end
with a random 4 digit number. Having a Weird subscription allows you to choose a name
without the number.
</p>
{/if}

<label class="label">
<span>Handle</span>

<div class="input-group input-group-divider grid-cols-[auto_1fr_auto]">
<div class="input-group-shim">@</div>
<input type="text" bind:this={input} bind:value={handle} placeholder="name" />
<div class="input-group-shim">.{env.PUBLIC_USER_DOMAIN_PARENT}</div>
<div class="input-group-shim">
{fullUsernameSuffix}
</div>
</div>
</label>
<div class="prose-invert mb-3 mt-8">
<p>
Claim <code>@{handle ? handle : '[name]'}.{env.PUBLIC_USER_DOMAIN_PARENT}</code> instantly!
Claim <code>@{handle ? handle : '[name]'}{fullUsernameSuffix}</code> instantly!
</p>
<p class="mt-3">
If you have your own web domain, you can also <button
Expand All @@ -123,12 +154,20 @@
</p>
</div>
{:else if selectedTab === 1}
{#if !subscriptionInfo?.isSubscribed}
<p class="flex items-center gap-3 pb-4 text-lg text-error-200">
<Icon icon="material-symbols:error-outline" font-size={40} />
Setting a custom domain as your handle requires a subscription.
</p>
{/if}

<label class="label">
<span>Web Domain</span>
<input
class="input"
type="text"
bind:value={domain}
disabled={!subscriptionInfo?.isSubscribed}
placeholder="name.example.com"
/>
</label>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
await fetch(`/${$page.params.username}/settings/deleteProfile`, {
method: 'post'
});
await goto('/claim-username', { invalidateAll: true, replaceState: true });
await goto('/claim-handle', { invalidateAll: true, replaceState: true });
modalStore.close();
}
</script>
Expand Down
13 changes: 13 additions & 0 deletions src/routes/(app)/[username]/settings/handle/+server.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { billing } from '$lib/billing';
import { getSession } from '$lib/rauthy/server';
import { usernames } from '$lib/usernames';
import { type RequestHandler, json } from '@sveltejs/kit';
Expand Down Expand Up @@ -27,6 +28,18 @@ export const POST: RequestHandler = async ({ request, fetch }) => {
}
const oldUsername = await usernames.getByRauthyId(sessionInfo.user_id);

const subscriptionInfo = await billing.getSubscriptionInfo(sessionInfo.user_id);

if (!subscriptionInfo.isSubscribed) {
if ('domain' in parsed.data) {
return json({ error: 'Cannot set username to custom domain without a subscription.' });
} else if (!usernames.validUnsubscribedUsernameRegex.test(parsed.data.username)) {
return json({
error: 'Cannot claim username without a 4 digit suffix without a subscription'
});
}
}

try {
await usernames.claim(parsed.data, sessionInfo.user_id);
if (oldUsername) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,26 +7,37 @@ import { RawImage } from 'leaf-proto/components.js';

import { createAvatar } from '@dicebear/core';
import { glass } from '@dicebear/collection';
import { billing } from '$lib/billing.js';
import { validUnsubscribedUsernameRegex } from '$lib/usernames/client.js';

export const load: PageServerLoad = async ({ fetch, request }) => {
const { sessionInfo } = await getSession(fetch, request);
if (!sessionInfo) return redirect(303, '/login');
const username = await usernames.getByRauthyId(sessionInfo.user_id);
if (username) {
return redirect(303, `/${username}`);
} else {
const subscriptionInfo = await billing.getSubscriptionInfo(sessionInfo.user_id);
return { subscriptionInfo };
}
};

export const actions = {
claimUsername: async ({ fetch, request }) => {
claimHandle: async ({ fetch, request }) => {
const { sessionInfo } = await getSession(fetch, request);
if (!sessionInfo) return fail(403, { error: 'Not logged in' });

const formData = await request.formData();
const username = formData.get('username') as string;
if (!username) return fail(400, { error: 'Username not provided ' });

const subscriptionInfo = await billing.getSubscriptionInfo(sessionInfo.user_id);

try {
if (!subscriptionInfo.isSubscribed && !username.match(validUnsubscribedUsernameRegex)) {
return fail(400,{ error: '' });
}

await usernames.claim({ username }, sessionInfo.user_id);
const profileLink = await profileLinkById(sessionInfo.user_id);
const avatar = createAvatar(glass, { seed: sessionInfo.user_id, radius: 50 });
Expand Down
10 changes: 10 additions & 0 deletions src/routes/(app)/claim-handle/+page.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<script lang="ts">
import type { ActionData, PageData } from './$types';
import ClaimHandlePage from './components/ClaimHandlePage.svelte';
const { form, data }: { form: ActionData; data: PageData } = $props();
const action = '?/claimHandle';
</script>

<ClaimHandlePage {form} {action} subscriptionInfo={data.subscriptionInfo} />
56 changes: 56 additions & 0 deletions src/routes/(app)/claim-handle/components/ClaimHandleForm.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<script lang="ts">
import { env } from '$env/dynamic/public';
import type { UserSubscriptionInfo } from '$lib/billing';
import { genRandomUsernameSuffix } from '$lib/usernames/client';
import Icon from '@iconify/svelte';
const {
error,
action,
subscriptionInfo
}: {
error: string | undefined;
action: string;
subscriptionInfo: UserSubscriptionInfo;
} = $props();
let handle = $state('');
let randomNumberSuffix = $state(genRandomUsernameSuffix());
let fullHandleSuffix = $derived(
(subscriptionInfo?.isSubscribed ? '' : randomNumberSuffix) + '.' + env.PUBLIC_USER_DOMAIN_PARENT
);
let handleWithSuffix = $derived(
subscriptionInfo?.isSubscribed ? handle : handle + randomNumberSuffix
);
</script>

<form class="card m-8 flex max-w-[40em] flex-col gap-4 p-6" method="post" {action}>
<h1 class="text-2xl font-bold">Claim Handle</h1>

{#if error}
<aside class="alert variant-ghost-error w-80">
<div class="alert-message">
<p>{error}</p>
</div>
</aside>
{/if}

<p>Choose a handle for your Weird profile! You will be able to change this later.</p>

{#if !subscriptionInfo?.isSubscribed}
<p class="text- flex items-center gap-3 pb-4 text-secondary-200">
<Icon icon="material-symbols:error-outline" font-size={40} /> Your handle will end with a random
4 digit number. Having a Weird subscription allows you to choose a name without the number.
</p>
{/if}

<div class="input-group input-group-divider grid-cols-[auto_1fr_auto]">
<div class="input-group-shim">@</div>
<input placeholder="handle" bind:value={handle} />
<input type="hidden" name="username" value={handleWithSuffix} />
<div class="input-group-shim">
{fullHandleSuffix}
</div>
</div>
<button class="variant-ghost btn">Claim</button>
</form>
15 changes: 15 additions & 0 deletions src/routes/(app)/claim-handle/components/ClaimHandlePage.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<script lang="ts">
import type { UserSubscriptionInfo } from '$lib/billing';
import ClaimHandleForm from './ClaimHandleForm.svelte';
const {
form,
action,
subscriptionInfo
}: { form: any; action: string; subscriptionInfo: UserSubscriptionInfo } = $props();
const error: string | undefined = form?.error;
</script>

<main class="flex flex-col items-center">
<ClaimHandleForm {error} {action} {subscriptionInfo} />
</main>
10 changes: 0 additions & 10 deletions src/routes/(app)/claim-username/+page.svelte

This file was deleted.

Loading

0 comments on commit 2534b41

Please sign in to comment.