From 9c1e72ed86a48babde06da6b05bb43c681faa489 Mon Sep 17 00:00:00 2001 From: TZGyn Date: Tue, 9 Jul 2024 04:09:45 +0800 Subject: [PATCH] added option to add custom domain to project in project settings page --- frontend/.env.example | 10 + frontend/.prettierrc | 1 + frontend/drizzle/0012_lame_vengeance.sql | 4 + frontend/drizzle/meta/0012_snapshot.json | 367 ++++++++++++++++++ frontend/drizzle/meta/_journal.json | 7 + .../ui/alert/alert-description.svelte | 13 + .../components/ui/alert/alert-title.svelte | 21 + .../src/lib/components/ui/alert/alert.svelte | 17 + frontend/src/lib/components/ui/alert/index.ts | 33 ++ frontend/src/lib/db/schema.ts | 11 + frontend/src/lib/server/domain.ts | 54 +++ frontend/src/lib/server/railway.ts | 119 ++++++ .../[id]/(components)/ShortenerCard.svelte | 45 +-- .../projects/[id]/settings/+page.server.ts | 190 ++++++++- .../(app)/projects/[id]/settings/+page.svelte | 246 +++++++++++- .../(app)/projects/[id]/settings/schema.ts | 4 + redirect/src/index.ts | 3 +- redirect/src/types.ts | 1 + 18 files changed, 1096 insertions(+), 50 deletions(-) create mode 100644 frontend/drizzle/0012_lame_vengeance.sql create mode 100644 frontend/drizzle/meta/0012_snapshot.json create mode 100644 frontend/src/lib/components/ui/alert/alert-description.svelte create mode 100644 frontend/src/lib/components/ui/alert/alert-title.svelte create mode 100644 frontend/src/lib/components/ui/alert/alert.svelte create mode 100644 frontend/src/lib/components/ui/alert/index.ts create mode 100644 frontend/src/lib/server/domain.ts create mode 100644 frontend/src/lib/server/railway.ts diff --git a/frontend/.env.example b/frontend/.env.example index d1001df..b366ae3 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,4 +1,14 @@ +ORIGIN=http://localhost:3000 PROTOCOL_HEADER=x-forwarded-proto 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 +PRIVATE_HOSTING_PROVIDER= # optional, (options: railway), if you don't have a hosting provider, leave it blank + +# Railway config (if you have a Railway hosting provider) +PRIVATE_RAILWAY_API_KEY= +PRIVATE_RAILWAY_ENVIRONMENT_ID= +PRIVATE_RAILWAY_PROJECT_ID= +PRIVATE_RAILWAY_SERVICE_ID= diff --git a/frontend/.prettierrc b/frontend/.prettierrc index 7e9a1e9..7fc6612 100644 --- a/frontend/.prettierrc +++ b/frontend/.prettierrc @@ -5,6 +5,7 @@ "trailingComma": "all", "printWidth": 70, "bracketSameLine": true, + "htmlWhitespaceSensitivity": "ignore", "plugins": [ "prettier-plugin-svelte", "prettier-plugin-tailwindcss" diff --git a/frontend/drizzle/0012_lame_vengeance.sql b/frontend/drizzle/0012_lame_vengeance.sql new file mode 100644 index 0000000..1242111 --- /dev/null +++ b/frontend/drizzle/0012_lame_vengeance.sql @@ -0,0 +1,4 @@ +ALTER TABLE "project" ADD COLUMN "domain_status" varchar(255) DEFAULT 'verified' NOT NULL;--> statement-breakpoint +ALTER TABLE "project" ADD COLUMN "enable_custom_domain" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "project" ADD COLUMN "custom_ip" varchar(255);--> statement-breakpoint +ALTER TABLE "project" ADD COLUMN "custom_domain_id" varchar(255); \ No newline at end of file diff --git a/frontend/drizzle/meta/0012_snapshot.json b/frontend/drizzle/meta/0012_snapshot.json new file mode 100644 index 0000000..2943375 --- /dev/null +++ b/frontend/drizzle/meta/0012_snapshot.json @@ -0,0 +1,367 @@ +{ + "id": "a4ffb6fc-ede8-431d-bd69-2429fbdc3ebd", + "prevId": "f5e2ea5c-7287-40ae-928a-3914f03045b5", + "version": "6", + "dialect": "postgresql", + "tables": { + "public.project": { + "name": "project", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "uuid": { + "name": "uuid", + "type": "uuid", + "primaryKey": false, + "notNull": false, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "qr_background": { + "name": "qr_background", + "type": "varchar(7)", + "primaryKey": false, + "notNull": true, + "default": "'#ffffff'" + }, + "qr_foreground": { + "name": "qr_foreground", + "type": "varchar(7)", + "primaryKey": false, + "notNull": true, + "default": "'#000000'" + }, + "domain_status": { + "name": "domain_status", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'verified'" + }, + "enable_custom_domain": { + "name": "enable_custom_domain", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "custom_ip": { + "name": "custom_ip", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "custom_domain_id": { + "name": "custom_domain_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "custom_domain": { + "name": "custom_domain", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.setting": { + "name": "setting", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "qr_background": { + "name": "qr_background", + "type": "varchar(7)", + "primaryKey": false, + "notNull": false + }, + "qr_foreground": { + "name": "qr_foreground", + "type": "varchar(7)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.shortener": { + "name": "shortener", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "link": { + "name": "link", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "ios": { + "name": "ios", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ios_link": { + "name": "ios_link", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "android": { + "name": "android", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "android_link": { + "name": "android_link", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "project_id": { + "name": "project_id", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "shortener_code_unique": { + "name": "shortener_code_unique", + "nullsNotDistinct": false, + "columns": [ + "code" + ] + } + } + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "uuid": { + "name": "uuid", + "type": "uuid", + "primaryKey": false, + "notNull": false, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + } + }, + "public.visitor": { + "name": "visitor", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "shortener_id": { + "name": "shortener_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "country_code": { + "name": "country_code", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "country": { + "name": "country", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "city": { + "name": "city", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "device_type": { + "name": "device_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "device_vendor": { + "name": "device_vendor", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "os": { + "name": "os", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "browser": { + "name": "browser", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": {}, + "schemas": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/frontend/drizzle/meta/_journal.json b/frontend/drizzle/meta/_journal.json index e0546ab..2125cc4 100644 --- a/frontend/drizzle/meta/_journal.json +++ b/frontend/drizzle/meta/_journal.json @@ -85,6 +85,13 @@ "when": 1718525942420, "tag": "0011_striped_paper_doll", "breakpoints": true + }, + { + "idx": 12, + "version": "6", + "when": 1720469333262, + "tag": "0012_lame_vengeance", + "breakpoints": true } ] } \ No newline at end of file diff --git a/frontend/src/lib/components/ui/alert/alert-description.svelte b/frontend/src/lib/components/ui/alert/alert-description.svelte new file mode 100644 index 0000000..5bbc85a --- /dev/null +++ b/frontend/src/lib/components/ui/alert/alert-description.svelte @@ -0,0 +1,13 @@ + + +
+ +
diff --git a/frontend/src/lib/components/ui/alert/alert-title.svelte b/frontend/src/lib/components/ui/alert/alert-title.svelte new file mode 100644 index 0000000..2795342 --- /dev/null +++ b/frontend/src/lib/components/ui/alert/alert-title.svelte @@ -0,0 +1,21 @@ + + + + + diff --git a/frontend/src/lib/components/ui/alert/alert.svelte b/frontend/src/lib/components/ui/alert/alert.svelte new file mode 100644 index 0000000..e1aa6df --- /dev/null +++ b/frontend/src/lib/components/ui/alert/alert.svelte @@ -0,0 +1,17 @@ + + + diff --git a/frontend/src/lib/components/ui/alert/index.ts b/frontend/src/lib/components/ui/alert/index.ts new file mode 100644 index 0000000..c21bc1e --- /dev/null +++ b/frontend/src/lib/components/ui/alert/index.ts @@ -0,0 +1,33 @@ +import { type VariantProps, tv } from "tailwind-variants"; + +import Root from "./alert.svelte"; +import Description from "./alert-description.svelte"; +import Title from "./alert-title.svelte"; + +export const alertVariants = tv({ + base: "relative w-full rounded-lg border p-4 [&:has(svg)]:pl-11 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", + + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, +}); + +export type Variant = VariantProps["variant"]; +export type HeadingLevel = "h1" | "h2" | "h3" | "h4" | "h5" | "h6"; + +export { + Root, + Description, + Title, + // + Root as Alert, + Description as AlertDescription, + Title as AlertTitle, +}; diff --git a/frontend/src/lib/db/schema.ts b/frontend/src/lib/db/schema.ts index 22d3859..c4cb84e 100644 --- a/frontend/src/lib/db/schema.ts +++ b/frontend/src/lib/db/schema.ts @@ -36,6 +36,17 @@ export const project = pgTable('project', { qr_foreground: varchar('qr_foreground', { length: 7 }) .default('#000000') .notNull(), + domain_status: varchar('domain_status', { + length: 255, + }) + .$type<'pending' | 'verified' | 'disabled'>() + .notNull() + .default('verified'), + enable_custom_domain: boolean('enable_custom_domain') + .notNull() + .default(false), + custom_ip: varchar('custom_ip', { length: 255 }), + custom_domain_id: varchar('custom_domain_id', { length: 255 }), custom_domain: varchar('custom_domain', { length: 255 }), }) diff --git a/frontend/src/lib/server/domain.ts b/frontend/src/lib/server/domain.ts new file mode 100644 index 0000000..3d94d00 --- /dev/null +++ b/frontend/src/lib/server/domain.ts @@ -0,0 +1,54 @@ +import { env } from '$env/dynamic/private' + +export const checkDomainAvailable = async (domain: string) => { + if (env.PRIVATE_HOSTING_PROVIDER === 'railway') { + const { railwayClient } = await import('./railway') + + const available = await railwayClient.checkDomain(domain) + + return available + } else { + return true + } +} + +export const createCustomDomain = async (domain: string) => { + if (env.PRIVATE_HOSTING_PROVIDER === 'railway') { + const { railwayClient } = await import('./railway') + + const response = await railwayClient.createCustomDomain(domain) + + if (!response.success) { + return { success: false, id: undefined, ip: undefined } as const + } + + return { + success: true, + id: response.domain.id, + ip: response.domain.record, + } as const + } else { + return { id: undefined, ip: undefined } as const + } +} + +export const deleteCustomDomain = async (domain: string | null) => { + if (!domain) { + return { success: true } as const + } + if (env.PRIVATE_HOSTING_PROVIDER === 'railway') { + const { railwayClient } = await import('./railway') + + const response = await railwayClient.deleteCustomDomain(domain) + + if (!response.success) { + return { success: false } as const + } + + return { + success: true, + } as const + } else { + return { success: true } as const + } +} diff --git a/frontend/src/lib/server/railway.ts b/frontend/src/lib/server/railway.ts new file mode 100644 index 0000000..b0a5a1b --- /dev/null +++ b/frontend/src/lib/server/railway.ts @@ -0,0 +1,119 @@ +import { env } from '$env/dynamic/private' + +class RailwayAPI { + constructor(private apiKey: string) {} + + private sendRequest(query: string, variables: any) { + return fetch(`https://backboard.railway.app/graphql/v2`, { + 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 customDomainCreate($input: CustomDomainCreateInput!) { + customDomainCreate(input: $input) { + __typename + createdAt + deletedAt + domain + environmentId + id + projectId + serviceId + status { + dnsRecords { + requiredValue + } + } + updatedAt + } + } + ` + const variables = { + input: { + domain: domain, + environmentId: env.PRIVATE_RAILWAY_ENVIRONMENT_ID, + projectId: env.PRIVATE_RAILWAY_PROJECT_ID, + serviceId: env.PRIVATE_RAILWAY_SERVICE_ID, + }, + } + const response = await this.sendRequest(query, variables) + const data: any = await response.json() + if (!data.errors) { + console.log('Railway Create Domain', data) + return { + success: true, + domain: { + id: data.data.customDomainCreate.id as string, + record: data.data.customDomainCreate.status.dnsRecords[0] + .requiredValue as string, + }, + } as const + } else { + return { + success: false, + message: 'Something went wrong', + } as const + } + } + + async deleteCustomDomain(id: string) { + const query = ` + mutation customDomainDelete($id: String!) { + customDomainDelete(id: $id) + + } + ` + const variables = { + id: id, + } + const response = await this.sendRequest(query, variables) + if (response.ok) { + const data: any = await response.json() + console.log('Railway Delete Domain', data) + return { + success: true, + } as const + } else { + return { + success: false, + } as const + } + } +} + +export const railwayClient = new RailwayAPI( + env.PRIVATE_RAILWAY_API_KEY, +) diff --git a/frontend/src/routes/(app)/projects/[id]/(components)/ShortenerCard.svelte b/frontend/src/routes/(app)/projects/[id]/(components)/ShortenerCard.svelte index 62e06f6..188ca17 100644 --- a/frontend/src/routes/(app)/projects/[id]/(components)/ShortenerCard.svelte +++ b/frontend/src/routes/(app)/projects/[id]/(components)/ShortenerCard.svelte @@ -7,7 +7,6 @@ import * as Avatar from '$lib/components/ui/avatar' import { Badge } from '$lib/components/ui/badge' import { ScrollArea } from '$lib/components/ui/scroll-area' - import { Separator } from '$lib/components/ui/separator' import type { Shortener, Project, Setting } from '$lib/db/types' import { BarChart, @@ -68,11 +67,15 @@ } const hostDomain = 'https://' + shortener.link.split('/')[2] + + const shortenerUrl = selected_project.enable_custom_domain + ? selected_project.custom_domain || shortener_url + : shortener_url - + -
+
+ class="max-w-[250px] overflow-x-clip overflow-ellipsis whitespace-nowrap"> {shortener.link}
@@ -95,12 +98,12 @@
+ class="flex items-center gap-2 text-sm text-muted-foreground"> - {shortener_url + '/' + shortener.code} + {shortenerUrl + '/' + shortener.code}
@@ -112,13 +115,6 @@ - - - - - - - @@ -126,13 +122,13 @@ selected_project.uuid || '', shortener.code, )}> - + Edit openDeleteDialog(shortener.code)} - class="flex gap-2 items-center text-destructive data-[highlighted]:bg-destructive"> + class="flex items-center gap-2 text-destructive data-[highlighted]:bg-destructive"> Delete @@ -142,11 +138,11 @@ -
+
+ + + + + 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. + + + + Cancel + { + await fetch('?/enable_custom_domain', { + method: 'POST', + headers: { + 'Content-Type': 'multipart/form-data', + }, + }) + await invalidateAll() + }}> + Continue + + + + + {:else} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {/if} +
+
+ + + {#if data.project.enable_custom_domain} +
+ + + Add Custom Domain + + + + + + + Update Project Domain (leave blank to use default) + + +

Only include the domain name, not the protocol.

+

Make sure the domain is pointing to our server.

+

Please contact us if you need a custom domain.

+
+
+ + {#if $customDomainSubmitting} + + {/if} + Update + +
+ +
+
+ {/if} +

Settings

@@ -51,22 +268,18 @@

Danger Zone

-
+
Delete Project - Permanently delete your project + + Permanently delete your project +
(deleteDialogOpen = open)}> - + @@ -88,7 +301,7 @@ {...attrs} bind:value={$formData.deleteShorteners} type="hidden" /> -
+
-
+
+ on:click={() => (deleteDialogOpen = false)}> + Cancel + {#if $submitting} diff --git a/frontend/src/routes/(app)/projects/[id]/settings/schema.ts b/frontend/src/routes/(app)/projects/[id]/settings/schema.ts index 72d7fa6..8219456 100644 --- a/frontend/src/routes/(app)/projects/[id]/settings/schema.ts +++ b/frontend/src/routes/(app)/projects/[id]/settings/schema.ts @@ -12,6 +12,10 @@ export const formSchema = z.object({ .max(7), }) +export const customDomainFormSchema = z.object({ + domain: z.string(), +}) + export type FormSchema = typeof formSchema export const deleteSchema = z.object({ diff --git a/redirect/src/index.ts b/redirect/src/index.ts index 671f8d4..7922f3f 100644 --- a/redirect/src/index.ts +++ b/redirect/src/index.ts @@ -34,10 +34,11 @@ app.get( .selectAll('shortener') .select(['project.custom_domain as domain']) .where('shortener.code', '=', shortenerCode) + .where('project.custom_domain', '=', domain) .orderBy('created_at', 'desc') if (domain) { - query.where('project.custom_domain', '=', domain) + query.where('project.enable_custom_domain', '=', true) } const shortener = await query.execute() diff --git a/redirect/src/types.ts b/redirect/src/types.ts index 74ee79c..476d0a3 100644 --- a/redirect/src/types.ts +++ b/redirect/src/types.ts @@ -67,6 +67,7 @@ export interface ProjectTable { name: string userId: number custom_domain: string | null + enable_custom_domain: boolean } export type Project = Selectable