Skip to content

Commit

Permalink
feat: add custom domain support.
Browse files Browse the repository at this point in the history
  • Loading branch information
zicklag committed Jul 12, 2024
1 parent f489581 commit df0a16f
Show file tree
Hide file tree
Showing 15 changed files with 471 additions and 55 deletions.
141 changes: 130 additions & 11 deletions backend/src/routes/profile.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
use std::{collections::HashMap, str::FromStr};
use std::str::FromStr;

use axum::{
extract::{DefaultBodyLimit, Multipart},
response::Response,
routing::delete,
};
use futures::{pin_mut, StreamExt};
use once_cell::sync::Lazy;
use serde::{Deserialize, Serialize};
use weird::{
db::StringSerde,
gdata::{GStoreBackend, Key},
iroh::docs::AuthorId,
profile::{Profile, Username},
};
Expand All @@ -16,11 +18,24 @@ use crate::ARGS;

use super::*;

#[derive(Serialize, Deserialize)]
pub struct ProfileWithDomain {
#[serde(flatten)]
pub profile: Profile,
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub custom_domain: Option<String>,
}

static DOMAINS_STORAGE_KEY: Lazy<Key> = Lazy::new(|| Key::from(["weird_backend", "v1", "domains"]));

pub fn install(router: Router<AppState>) -> Router<AppState> {
router
.route("/usernames", get(get_usernames))
.route("/domains", get(get_domains))
.route("/profiles", get(get_profiles))
.route("/profile/username/:username", get(get_profile_by_name))
.route("/profile/domain/:domain", get(get_profile_by_domain))
.route("/profile/domain/:domain", post(set_domain_for_profile))
.route(
"/profile/username/:username/avatar",
get(get_profile_avatar_by_name),
Expand All @@ -35,17 +50,22 @@ pub fn install(router: Router<AppState>) -> Router<AppState> {
.route("/profile/:user_id", delete(delete_profile))
}

async fn get_usernames(
state: State<AppState>,
) -> AppResult<Json<HashMap<String, StringSerde<AuthorId>>>> {
let mut usernames = HashMap::default();
async fn get_domains(state: State<AppState>) -> AppResult<Json<Vec<String>>> {
let mut domains = Vec::default();
let stream = state.weird.get_usernames().await?;
pin_mut!(stream);
while let Some(result) = stream.next().await {
let (username, author_id) = result?;
usernames.insert(username, author_id.into());
let profile = state.weird.get_profile_value(author_id).await?;
let custom_domain = profile
.get_key("custom_domain")
.await?
.as_str()
.map(ToOwned::to_owned)
.ok();
domains.push(custom_domain.unwrap_or_else(|| format!("{username}.{}", ARGS.domain)));
}
Ok(Json(usernames))
Ok(Json(domains))
}

async fn get_profiles(state: State<AppState>) -> AppResult<Json<Vec<Profile>>> {
Expand All @@ -72,16 +92,114 @@ async fn get_profile_by_name(
Ok(Json(state.weird.get_profile_by_name(&username).await?))
}

async fn get_profile_by_domain(
state: State<AppState>,
Path(domain): Path<String>,
) -> AppResult<Json<Profile>> {
let domains = state
.weird
.graph
.get((state.weird.ns, &*DOMAINS_STORAGE_KEY))
.await?;
let author_bytes = domains.get_key(&domain).await?;
if author_bytes.is_null() {
return Err(anyhow::format_err!("User not found for domain: {domain}").into());
}
let author_bytes: [u8; 32] = author_bytes.as_bytes()?[..].try_into()?;
let author = AuthorId::from(author_bytes);
Ok(Json(state.weird.get_profile(author).await?))
}

#[derive(Deserialize)]
struct SetDomainReq {
domain: Option<String>,
}

async fn set_domain_for_profile(
state: State<AppState>,
Path(user_id): Path<String>,
Json(data): Json<SetDomainReq>,
) -> AppResult<()> {
if let Some(domain) = &data.domain {
if domain.ends_with(&ARGS.domain) {
return Err(anyhow::format_err!(
"Cannot set domain to a sub-domain of {}: {}",
ARGS.domain,
domain
)
.into());
}
}

let domains = state
.weird
.graph
.get_or_init_map((state.weird.ns, &*DOMAINS_STORAGE_KEY))
.await?;
let author = state.weird.get_or_init_author(&user_id).await?;
let profile = state.weird.get_profile_value(author).await?;

let delete_previous_domain_mapping = || async {
// See if there was a previous username
let previous_domain = profile.get_key("custom_domain").await?;
if let Ok(previous_domain) = previous_domain.as_str() {
// Delete the username reservation
domains.del_key(previous_domain).await?;
}
Ok::<_, anyhow::Error>(())
};

// If there is a username set
if let Some(new_domain) = &data.domain {
// Check if there is already an author set for that domain
let existing_author = &domains.get_key(new_domain).await?;
if !existing_author.is_null() {
let existing_author_key: AuthorId =
<[u8; 32]>::try_from(&existing_author.as_bytes()?[..])?.into();
// Don't allow replacing existing author in username
if existing_author_key != author {
return Err(anyhow::format_err!("domain already taken").into());
}

// If this domain is available
} else {
// Free the old domain mapping
delete_previous_domain_mapping().await?;

// Claim the username
domains.set_key(new_domain, &author.as_bytes()[..]).await?;
profile.set_key("custom_domain", new_domain).await?;
}
} else {
// Free the old username mapping
delete_previous_domain_mapping().await?;
profile.del_key("custom_domain").await?;
}

Ok(())
}

async fn delete_profile(state: State<AppState>, Path(user_id): Path<String>) -> AppResult<()> {
Ok(state.weird.delete_profile(&user_id).await?)
}

async fn get_profile(
state: State<AppState>,
Path(user_id): Path<String>,
) -> AppResult<Json<Profile>> {
) -> AppResult<Json<ProfileWithDomain>> {
let author = state.weird.get_or_init_author(user_id).await?;
Ok(Json(state.weird.get_profile(author).await?))
let value = state.weird.get_profile_value(author).await?;
let profile = Profile::from_value(&value).await?;
let custom_domain = value
.get_key("custom_domain")
.await?
.as_str()
.map(ToOwned::to_owned)
.ok();
Ok(Json(ProfileWithDomain {
profile,
custom_domain,
}))
}

async fn post_profile(
Expand All @@ -91,6 +209,7 @@ async fn post_profile(
) -> AppResult<()> {
let author = state.weird.get_or_init_author(user_id).await?;
state.weird.set_profile(author, new_profile.0).await?;

Ok(())
}

Expand Down
13 changes: 9 additions & 4 deletions backend/weird/src/profile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use std::{collections::HashMap, fmt::Debug, io::Cursor, str::FromStr};

use anyhow::Result;
use futures::{pin_mut, Stream, StreamExt, TryStreamExt};
use gdata::{GStoreBackend, GStoreValue, Key, KeySegment, Value};
use gdata::{GStoreBackend, GStoreValue, IrohGStore, Key, KeySegment, Value};
use iroh::docs::{AuthorId, DocTicket, NamespaceId};
use once_cell::sync::Lazy;
use serde::{Deserialize, Serialize};
Expand Down Expand Up @@ -521,14 +521,19 @@ impl<S> Weird<S> {
}))
}

/// Get a user profile.
#[tracing::instrument(skip(self))]
pub async fn get_profile(&self, author: AuthorId) -> Result<Profile> {
pub async fn get_profile_value(&self, author: AuthorId) -> Result<GStoreValue<IrohGStore>> {
let profiles = self
.graph
.get_or_init_map((self.ns, &*PROFILES_KEY))
.await?;
let profile = profiles.get_or_init_map(&author.as_bytes()[..]).await?;
Ok(profile)
}

/// Get a user profile.
#[tracing::instrument(skip(self))]
pub async fn get_profile(&self, author: AuthorId) -> Result<Profile> {
let profile = self.get_profile_value(author).await?;
let profile = Profile::from_value(&profile).await?;
Ok(profile)
}
Expand Down
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,5 +46,8 @@
"vite-plugin-top-level-await": "^1.4.1",
"vite-plugin-wasm": "^3.3.0"
},
"type": "module"
"type": "module",
"dependencies": {
"keyv": "^4.5.4"
}
}
4 changes: 4 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 8 additions & 8 deletions src/hooks.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { browser } from '$app/environment';
import { env } from '$env/dynamic/public';
import type { Reroute } from '@sveltejs/kit';

Expand All @@ -6,22 +7,21 @@ const subdomainRegex = new RegExp(
);

export const reroute: Reroute = ({ url }) => {
if (url.host === env.PUBLIC_DOMAIN) {
if (url.host == env.PUBLIC_DOMAIN || url.pathname.startsWith('/dns-challenge')) {
return url.pathname;
}

if (url.host == env.PUBLIC_TRAEFIK_CONFIG_HOST) {
return '/traefik-config';
}

let username = url.host.match(subdomainRegex)?.[1];
if (!username) {
throw 'Invalid domain';
}

if (url.pathname == '/' || url.pathname == '') {
return `/subsite/${username}`;
} else if (url.pathname.startsWith(`/u/${username}`)) {
let usernameSubdomain = url.host.match(subdomainRegex)?.[1];
const subsite = usernameSubdomain ? usernameSubdomain : url.host;
return `/subsite/${subsite}`;
} else if (url.pathname.startsWith(`/u/`)) {
return url.pathname;
}

throw 'Invalid domain';
};
13 changes: 13 additions & 0 deletions src/lib/dns-challenge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import Keyv from 'keyv';

const store = new Keyv({ namespace: 'dns-challenge' });

export const createChallenge = async (userId: string): Promise<string> => {
const challengeId = crypto.randomUUID();
await store.set(challengeId, userId, 30 * 60 * 1000);
return challengeId;
};

export const validateChallenge = async (challengeId: string, userId: string): Promise<boolean> => {
return (await store.get(challengeId)) == userId;
};
85 changes: 85 additions & 0 deletions src/routes/(app)/account/[user_id]/custom-domain/+page.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import type { Actions, PageServerLoad } from './$types';
import { backendFetch } from '$lib/backend';
import { getSession } from '$lib/rauthy/server';
import type { Profile } from '../../../auth/v1/account/+page.server';
import { checkResponse } from '$lib/utils';
import { env } from '$env/dynamic/public';
import { createChallenge } from '$lib/dns-challenge';

export const load: PageServerLoad = async ({
fetch,
request
}): Promise<{ profile?: Profile; serverIp?: string; dnsChallenge?: string }> => {
let serverIp;
let dnsChallenge;
let resp;
try {
resp = await fetch(
`https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(env.PUBLIC_DOMAIN)}`,
{
headers: [['accept', 'application/dns-json']]
}
);
await checkResponse(resp);
const serverIpJson: { Answer?: { name: string; data: string }[] } = await resp.json();
serverIp = serverIpJson.Answer?.[0].data;
} catch (e) {
console.error('Error fetching DNS over http', e);
}

let { userInfo } = await getSession(fetch, request);
if (userInfo) {
dnsChallenge = await createChallenge(userInfo.id);
resp = await backendFetch(fetch, `/profile/${userInfo.id}`);
await checkResponse(resp);
const profile: Profile = await resp.json();

return { profile, serverIp, dnsChallenge };
} else {
return { serverIp, dnsChallenge };
}
};

export const actions = {
default: async ({ request, fetch }) => {
let { userInfo } = await getSession(fetch, request);
if (!userInfo) {
throw 'User not logged in';
}
const formData = await request.formData();
const customDomain = formData.get('custom_domain');
let resp;

if (customDomain && customDomain != '') {
const publicUrl = new URL(env.PUBLIC_URL);

const dnsChallenge = await createChallenge(userInfo.id);
try {
resp = await fetch(
`${publicUrl.protocol}//${customDomain}/dns-challenge/${dnsChallenge}/${userInfo?.id}`
);
} catch (_) {}
if (resp?.status != 200) {
throw 'Error validating DNS challenge';
}

resp = await backendFetch(fetch, `/profile/domain/${userInfo.id}`, {
method: 'post',
headers: [['content-type', 'application/json']],
body: JSON.stringify({
domain: customDomain
})
});
await checkResponse(resp);
} else {
resp = await backendFetch(fetch, `/profile/domain/${userInfo.id}`, {
method: 'post',
headers: [['content-type', 'application/json']],
body: JSON.stringify({
domain: null
})
});
await checkResponse(resp);
}
}
} satisfies Actions;
Loading

0 comments on commit df0a16f

Please sign in to comment.