diff --git a/frontend/.env.example b/frontend/.env.example index 24f1a79..7aa65a1 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -4,9 +4,9 @@ HOST_HEADER=x-forwarded-host DATABASE_URL=postgres://postgres:password@127.0.0.1:5432/link-shortener PUBLIC_SHORTENER_URL=localhost:3000 APP_ENV=local -PUBLIC_SHORTENER_IP=1.1.1.1 +PUBLIC_SHORTENER_IP=1.1.1.1 # ignore if using other hosting providers listed below -PRIVATE_HOSTING_PROVIDER= # optional, (options: railway), if you don't have a hosting provider, leave it blank +PRIVATE_HOSTING_PROVIDER= # optional, (options: railway, fly.io), if you don't have a hosting provider, leave it blank PRIVATE_MAIL_PROVIDER= # optional, (options: resend), if you don't have a mail provider, leave it blank # Railway config (if you have a Railway hosting provider) @@ -15,5 +15,12 @@ PRIVATE_RAILWAY_ENVIRONMENT_ID= PRIVATE_RAILWAY_PROJECT_ID= PRIVATE_RAILWAY_SERVICE_ID= +# Fly.io config (if you have a Fly.io hosting provider) +PRIVATE_FLYIO_API_KEY= +PRIVATE_FLYIO_APP_ID= +PRIVATE_FLYIO_CNAME= +PRIVATE_FLYIO_IPV4= +PRIVATE_FLYIO_IPV6= + # Resend config (if you have a Resend hosting provider) PRIVATE_RESEND_API_KEY= diff --git a/frontend/bun.lockb b/frontend/bun.lockb index 5302c18..41bf9ce 100755 Binary files a/frontend/bun.lockb and b/frontend/bun.lockb differ diff --git a/frontend/src/lib/server/domain.ts b/frontend/src/lib/server/domain.ts index 3d94d00..ef5fd69 100644 --- a/frontend/src/lib/server/domain.ts +++ b/frontend/src/lib/server/domain.ts @@ -27,6 +27,20 @@ export const createCustomDomain = async (domain: string) => { id: response.domain.id, ip: response.domain.record, } as const + } else if (env.PRIVATE_HOSTING_PROVIDER == 'fly.io') { + const { flyioClient } = await import('./flyio') + + const response = await flyioClient.createCustomDomain(domain) + + if (!response.success) { + return { success: false, id: undefined, ip: undefined } as const + } + + return { + success: true, + id: domain, + ip: undefined, + } as const } else { return { id: undefined, ip: undefined } as const } @@ -45,6 +59,18 @@ export const deleteCustomDomain = async (domain: string | null) => { return { success: false } as const } + return { + success: true, + } as const + } else if (env.PRIVATE_HOSTING_PROVIDER === 'fly.io') { + const { flyioClient } = await import('./flyio') + + const response = await flyioClient.deleteCustomDomain(domain) + + if (!response.success) { + return { success: false } as const + } + return { success: true, } as const diff --git a/frontend/src/lib/server/flyio.ts b/frontend/src/lib/server/flyio.ts new file mode 100644 index 0000000..0353e86 --- /dev/null +++ b/frontend/src/lib/server/flyio.ts @@ -0,0 +1,122 @@ +import { env } from '$env/dynamic/private' + +class FlyioAPI { + constructor(private apiKey: string) {} + + private sendRequest(query: string, variables: any) { + return fetch(`https://api.fly.io/graphql`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.apiKey}`, + }, + body: JSON.stringify({ query, variables }), + }) + } + + async checkDomain(domain: string) { + const query = ` + query customDomainAvailable($domain: String!) { + customDomainAvailable(domain: $domain) { + __typename + available + message + } + } + ` + const variables = { + domain: domain, + } + const response = await this.sendRequest(query, variables) + if (response.ok) { + const data: any = await response.json() + if (data.data.customDomainAvailable.available) { + return true + } else { + return false + } + } else { + return false + } + } + + async createCustomDomain(domain: string) { + const query = ` + mutation($appId: ID!, $hostname: String!) { + addCertificate(appId: $appId, hostname: $hostname) { + certificate { + configured + acmeDnsConfigured + acmeAlpnConfigured + certificateAuthority + certificateRequestedAt + dnsProvider + dnsValidationInstructions + dnsValidationHostname + dnsValidationTarget + hostname + id + source + } + } + } + ` + const variables = { + appId: env.PRIVATE_FLYIO_APP_ID, + hostname: domain, + } + const response = await this.sendRequest(query, variables) + const data: any = await response.json() + if (!data.errors) { + return { + success: true, + } as const + } else { + return { + success: false, + message: 'Something went wrong', + } as const + } + } + + async deleteCustomDomain(domain: string) { + const query = ` + mutation ($appId: ID!, $hostname: String!) { + deleteCertificate(appId: $appId, hostname: $hostname) { + certificate { + configured + acmeDnsConfigured + acmeAlpnConfigured + certificateAuthority + certificateRequestedAt + dnsProvider + dnsValidationInstructions + dnsValidationHostname + dnsValidationTarget + hostname + id + source + } + } + } + ` + const variables = { + appId: env.PRIVATE_FLYIO_APP_ID, + hostname: domain, + } + const response = await this.sendRequest(query, variables) + const data: any = await response.json() + if (!data.errors) { + console.log('Fly.io Delete Domain', data) + return { + success: true, + } as const + } else { + return { + success: false, + } as const + } + } +} + +export const flyioClient = new FlyioAPI(env.PRIVATE_FLYIO_API_KEY) diff --git a/frontend/src/routes/(app)/projects/[id]/settings/(components)/dns-info.svelte b/frontend/src/routes/(app)/projects/[id]/settings/(components)/dns-info.svelte new file mode 100644 index 0000000..efc706d --- /dev/null +++ b/frontend/src/routes/(app)/projects/[id]/settings/(components)/dns-info.svelte @@ -0,0 +1,72 @@ + + + + + {#if cname_record} + CNAME + {/if} + {#if a_record || aaaa_record} + A and AAAA + {/if} + + {#if cname_record} + + + + Type + Value + + + CNAME + {cname_record} + + + + {/if} + {#if a_record || aaaa_record} + +
+ {#if a_record} + + + Type + Value + + + A + {a_record} + + + {/if} + {#if aaaa_record} + + + Type + Value + + + AAAA + {aaaa_record} + + + {/if} +
+
+ {/if} +
diff --git a/frontend/src/routes/(app)/projects/[id]/settings/(components)/dns-tooltip.svelte b/frontend/src/routes/(app)/projects/[id]/settings/(components)/dns-tooltip.svelte new file mode 100644 index 0000000..cce878b --- /dev/null +++ b/frontend/src/routes/(app)/projects/[id]/settings/(components)/dns-tooltip.svelte @@ -0,0 +1,61 @@ + + + + + + {#if custom_ip} + {custom_ip} + {:else if cname_record} + {cname_record} + {:else if a_record} + {a_record} + {:else if aaaa_record} + {aaaa_record} + {:else} + {'Public IP not found'} + {/if} + + + {#if custom_ip} +
+ {'Create a CNAME/ALIAS record for ' + + domain + + ' to ' + + custom_ip} +
+ {/if} + {#if cname_record} +
+ {'Create a CNAME/ALIAS record for ' + + domain + + ' to ' + + cname_record} +
+ {/if} + {#if a_record} +
+ {'Create a A record for ' + domain + ' to ' + a_record} +
+ {/if} + + {#if aaaa_record} +
+ {'Create a AAAA record for ' + domain + ' to ' + aaaa_record} +
+ {/if} + {#if !(custom_ip || cname_record || a_record || aaaa_record)} +
+ {'Public IP not found'} +
+ {/if} +
+
diff --git a/frontend/src/routes/(app)/projects/[id]/settings/+page.server.ts b/frontend/src/routes/(app)/projects/[id]/settings/+page.server.ts index c012a7b..3bc9598 100644 --- a/frontend/src/routes/(app)/projects/[id]/settings/+page.server.ts +++ b/frontend/src/routes/(app)/projects/[id]/settings/+page.server.ts @@ -19,10 +19,27 @@ import { createCustomDomain, deleteCustomDomain, } from '$lib/server/domain' +import { env } from '$env/dynamic/private' +import { PUBLIC_SHORTENER_IP } from '$env/static/public' export const load = (async (event) => { const { project } = await event.parent() + let cnameRecord = '' + let aRecord = '' + let aaaaRecord = '' + + const provider = env.PRIVATE_HOSTING_PROVIDER + + if (provider === 'fly.io') { + cnameRecord = env.PRIVATE_FLYIO_CNAME + aRecord = env.PRIVATE_FLYIO_IPV4 + aaaaRecord = env.PRIVATE_FLYIO_IPV6 + } else if (provider === 'railway') { + } else { + aRecord = PUBLIC_SHORTENER_IP + } + return { form: await superValidate( { @@ -43,6 +60,9 @@ export const load = (async (event) => { id: 'deleteProject', }, ), + cnameRecord, + aRecord, + aaaaRecord, } }) satisfies PageServerLoad @@ -201,6 +221,12 @@ export const actions: Actions = { return setError(form, 'domain', 'Domain is not available') } + const customDomain = await createCustomDomain(form.data.domain) + + if (!customDomain.success) { + return setError(form, 'domain', 'Cannot create custom domain') + } + const deleteOldCustomDomain = await deleteCustomDomain( existingProject.custom_domain_id, ) @@ -213,12 +239,6 @@ export const actions: Actions = { ) } - const customDomain = await createCustomDomain(form.data.domain) - - if (!customDomain.success) { - return setError(form, 'domain', 'Cannot create custom domain') - } - await db .update(projectTable) .set({ diff --git a/frontend/src/routes/(app)/projects/[id]/settings/+page.svelte b/frontend/src/routes/(app)/projects/[id]/settings/+page.svelte index 483bcc7..e0d0e0c 100644 --- a/frontend/src/routes/(app)/projects/[id]/settings/+page.svelte +++ b/frontend/src/routes/(app)/projects/[id]/settings/+page.svelte @@ -23,6 +23,8 @@ } from 'lucide-svelte' import { env } from '$env/dynamic/public' import { invalidateAll } from '$app/navigation' + import DnsInfo from './(components)/dns-info.svelte' + import DnsTooltip from './(components)/dns-tooltip.svelte' export let data @@ -68,7 +70,7 @@

Custom Domain

-

+

Update project domain.

@@ -79,11 +81,11 @@
{#if data.project.domain_status === 'pending'} - + {:else if data.project.domain_status === 'verified'} - + {:else if data.project.domain_status === 'disabled'} - + {/if}
@@ -95,33 +97,12 @@ custom domain - - - - {#if data.project.custom_ip} - {data.project.custom_ip} - {:else if env.PUBLIC_SHORTENER_IP} - {env.PUBLIC_SHORTENER_IP} - {:else} - {'Public IP not found'} - {/if} - - - {#if data.project.custom_ip} - {'Create a CNAME record for ' + - data.project.custom_domain + - ' to ' + - data.project.custom_ip} - {:else if env.PUBLIC_SHORTENER_IP} - {'Create a A record for ' + - data.project.custom_domain + - ' to ' + - env.PUBLIC_SHORTENER_IP} - {:else} - {'Public IP not found'} - {/if} - - + {:else} @@ -144,16 +125,16 @@ Are you absolutely sure? - - - - Disabling custom domain is not available yet. - - Enabling a custom domain will allow you to use your project with a custom domain. + {#if data.cnameRecord || data.aRecord || data.aaaaRecord} + + {/if} Cancel @@ -255,7 +236,7 @@

Settings

-

+

Update project settings.

@@ -268,11 +249,11 @@

Danger Zone

-
+
Delete Project - + Permanently delete your project
diff --git a/redirect/bun.lockb b/redirect/bun.lockb index 79e61a7..0983070 100755 Binary files a/redirect/bun.lockb and b/redirect/bun.lockb differ diff --git a/redirect/fly.toml b/redirect/fly.toml new file mode 100644 index 0000000..e4ec906 --- /dev/null +++ b/redirect/fly.toml @@ -0,0 +1,22 @@ +# fly.toml app configuration file generated for kon on 2024-07-28T10:01:44+08:00 +# +# See https://fly.io/docs/reference/configuration/ for information about how to use this file. +# + +app = 'kon' +primary_region = 'dfw' + +[build] + +[http_service] + internal_port = 3000 + force_https = true + auto_stop_machines = 'off' + auto_start_machines = true + min_machines_running = 0 + processes = ['app'] + +[[vm]] + memory = '1gb' + cpu_kind = 'shared' + cpus = 1 diff --git a/redirect/src/index.ts b/redirect/src/index.ts index e73a8ab..869383d 100644 --- a/redirect/src/index.ts +++ b/redirect/src/index.ts @@ -7,6 +7,7 @@ const fallback_url = Bun.env.FALLBACK_URL ?? 'https://app.kon.sh' const app_url = Bun.env.APP_URL ?? 'kon.sh' const geoipupdate_account_id = Bun.env.GEOIPUPDATE_ACCOUNT_ID const geoipupdate_license_key = Bun.env.GEOIPUPDATE_LICENSE_KEY +const hosting_provider = Bun.env.HOSTING_PROVIDER const app = new Elysia().use(cors()) @@ -20,7 +21,11 @@ app.get( const request_domain = request.headers.get('host') const domain = request_domain !== app_url ? request_domain : null - const ip = request.headers.get('x-forwarded-for') + const ip = request.headers.get( + hosting_provider === 'fly.io' + ? 'Fly-Client-IP' + : 'x-forwarded-for' + ) const WebServiceClient = require('@maxmind/geoip2-node').WebServiceClient