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 5931819
Show file tree
Hide file tree
Showing 15 changed files with 521 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.

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

const subdomainRegex = new RegExp(
`(.*)\.${env.PUBLIC_DOMAIN.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`
);

// // Install hack to make global `fetch()` work behind an http proxy during development
// import { Agent, ProxyAgent, Dispatcher, setGlobalDispatcher } from 'undici';
// // If we are in development mode and there is an `http_proxy` set
// if (process.env.NODE_ENV == 'development' && process.env.http_proxy && !browser) {
// // Collect the list of domains that we should not use a proxy for
// const noProxyList = (process.env.no_proxy && process.env.no_proxy.split(',')) || [];
// // Parse the proxy URL
// const proxyUrl = new URL(process.env.http_proxy);
// // Create an access token if the proxy requires authentication
// const token = proxyUrl.username && proxyUrl.password ?
// `Basic ${Buffer.from(`${proxyUrl.username}:${proxyUrl.password}`).toString('base64')}` : undefined;

// // Create a default agent that will be used for no_proxy origins
// const defaultAgent = new Agent();

// // Create an interceptor that will use the appropriate agent based on the origin and the no_proxy
// // environment variable.
// const noProxyInterceptor = (dispatch: Dispatcher['dispatch']): Dispatcher['dispatch'] => {
// return (opts, handler) => {
// let noProxy = false;
// for (const exclusion of noProxyList) {
// if (opts.origin?.toString().search(exclusion) != -1) {
// noProxy = true;
// break;
// }
// }
// return noProxy ?
// defaultAgent.dispatch(opts, handler) :
// dispatch(opts, handler);
// }
// };

// // Create a proxy agent that will send all requests through the configured proxy, unless the
// // noProxyInterceptor bypasses it.
// const proxyAgent = new ProxyAgent({
// uri: proxyUrl.protocol + proxyUrl.host,
// token,
// interceptors: {
// Client: [noProxyInterceptor]
// }
// });

// // Make sure our configured proxy agent is used for all `fetch()` requests globally.
// setGlobalDispatcher(proxyAgent);
// }

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;
};
Loading

0 comments on commit 5931819

Please sign in to comment.