init commit

This commit is contained in:
Luka Dekanozishvili 2026-01-05 17:39:59 +01:00
commit dfe324cf8f
43 changed files with 4237 additions and 0 deletions

View file

33
src/lib/ErrorPopup.svelte Normal file
View file

@ -0,0 +1,33 @@
<script lang="ts">
import { fade, fly } from "svelte/transition";
let { errorMessage = null as string | null, duration = 5000 } = $props();
$effect(() => {
if (!errorMessage) {
visible = false;
return;
}
visible = true;
if (timeoutId) clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
visible = false;
timeoutId = null;
}, duration);
});
let visible: boolean = $state(false);
let timeoutId: ReturnType<typeof setTimeout> | null = null;
</script>
{#if visible}
<div role="alert" class="alert alert-error font-bold mt-5 z-0"
in:fly={{ y: -70, duration: 500 }}
out:fade={{ duration: 300 }}
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0 stroke-current" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
<span>{errorMessage}</span>
</div>
{/if}

87
src/lib/MainNavbar.svelte Normal file
View file

@ -0,0 +1,87 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { PUBLIC_BACKEND_API_HOST } from "$env/static/public";
import { auth, setUserLoggedOut } from "./auth.svelte";
async function toggleMfa() {
try {
const res = await fetch(`${PUBLIC_BACKEND_API_HOST}/api/v1/user/update-mfa`, {
credentials: "include",
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enable_mfa: auth.isMfaEnabled }),
});
if (res.status === 401 || res.status === 500) {
setUserLoggedOut();
goto('/login');
throw new Error('Unauthorized');
} else if (res.status !== 204) {
let msg = await res.text();
throw new Error(`Failed updating MFA: ${msg}`);
}
} catch (err) {
// TODO: show error to user
console.error(err);
auth.isMfaEnabled = !auth.isMfaEnabled;
}
}
</script>
<div class="navbar bg-base-100 shadow-sm top-0 z-10 py-0">
<div class="navbar-start">
<div class="dropdown">
<div tabindex="0" role="button" class="btn btn-ghost lg:hidden">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h8m-8 6h16" /> </svg>
</div>
<ul
tabindex="-1"
class="menu menu-sm dropdown-content bg-base-100 rounded-box z-1 mt-3 w-52 p-2 shadow">
<li><a href="/">Home</a></li>
<li><a href="/about">About us</a></li>
<li><a href="/faq">FAQ</a></li>
</ul>
</div>
<a class="btn btn-ghost text-xl" href="/">HexName</a>
</div>
<div class="navbar-center hidden lg:flex">
<ul class="menu menu-horizontal px-1">
<li><a href="/">Home</a></li>
<li><a href="/about">About us</a></li>
<li><a href="/faq">FAQ</a></li>
</ul>
</div>
<div class="navbar-end">
{#if auth.isAuthenticated}
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-ghost btn-circle avatar">
<svg class="size-9" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="#dfe5ed"><path stroke-linecap="round" stroke-linejoin="round" d="M17.982 18.725A7.488 7.488 0 0 0 12 15.75a7.488 7.488 0 0 0-5.982 2.975m11.963 0a9 9 0 1 0-11.963 0m11.963 0A8.966 8.966 0 0 1 12 21a8.966 8.966 0 0 1-5.982-2.275M15 9.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" /></svg>
</div>
<ul
tabindex="-1"
class="menu menu-sm dropdown-content bg-base-200 rounded-box z-10 mt-3 w-52 p-2 shadow">
<li><text class="font-bold">{auth.userEmail}</text></li>
<li>
<div class="justify-between">
2-factor via email
<label class="toggle text-base-content outline-transparent">
<input
type="checkbox"
bind:checked={auth.isMfaEnabled}
on:change={toggleMfa}
/>
<svg class="outline-transparent" aria-label="disabled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18" /><path d="m6 6 12 12"/></svg>
<svg class="outline-transparent" aria-label="enabled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g stroke-linejoin="round" stroke-linecap="round" stroke-width="4" fill="none" stroke="currentColor"><path d="M20 6 9 17l-5-5"></path></g></svg>
</label>
</div>
</li>
<li><a href="/delete-account">Delete account</a></li>
<li><a href="/logout">Log out</a></li>
</ul>
</div>
{:else}
<a href="/register"><button class="btn btn-sm btn-primary rounded-lg mr-2">Create an account</button></a>
<a href="/login"><button class="btn btn-sm btn-secondary rounded-lg mr-2">Log in</button></a>
{/if}
</div>
</div>

261
src/lib/Record.svelte Normal file
View file

@ -0,0 +1,261 @@
<script lang="ts">
import { page } from "$app/state";
import { PUBLIC_BACKEND_API_HOST } from "$env/static/public";
import ErrorPopup from "./ErrorPopup.svelte";
import RecordExplanation from "./RecordExplanation.svelte";
import { getDisplayName, getDisplayValue, getNamePlaceholderText, getValuePlaceholderText, getValueTitleText, handleUiRecordUpdates, isRecordValid, type DnsRecord } from "./records.svelte";
let { records = $bindable(), record, i, fqdn, error }: { records: DnsRecord[], record: DnsRecord, i: number, fqdn: string, error: string } = $props();
let isEdit = $state(false);
let recordDraft = $state($state.snapshot(record));
let errorMessage = $state('');
function triggerMenu() {
if (isEdit) {
isEdit = false;
} else {
recordDraft = $state.snapshot(record);
recordDraft.name = recordDraft.name ? recordDraft.name : '@';
isEdit = true;
}
}
async function handleUpdateSubmit(e: SubmitEvent) {
errorMessage = isRecordValid(record.record_type, recordDraft.name, recordDraft.value, recordDraft.priority ?? 0, recordDraft.weight ?? 0, recordDraft.port ?? 0, recordDraft.comment, recordDraft.ttl);
if (errorMessage !== '') {
return;
};
let processedName = recordDraft.name === '@' ? '' : recordDraft.name
try {
let res;
if (record.record_type === "MX") {
let processedValue = recordDraft.value === '@' ? fqdn : recordDraft.value;
res = await fetch(`${PUBLIC_BACKEND_API_HOST}/api/v1/record`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({"record_id": record.id, "is_active": recordDraft.is_active, "name": processedName, "value": processedValue, "ttl": recordDraft.ttl, "comment": recordDraft.comment, "record_type": record.record_type, "subdomain_id": page.params.id, "priority": recordDraft.priority})
});
} else if (record.record_type === "SRV") {
let processedValue = recordDraft.value === '@' ? fqdn : recordDraft.value;
res = await fetch(`${PUBLIC_BACKEND_API_HOST}/api/v1/record`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({"record_id": record.id, "is_active": recordDraft.is_active, "name": processedName, "value": processedValue, "ttl": recordDraft.ttl, "comment": recordDraft.comment, "record_type": record.record_type, "subdomain_id": page.params.id, "priority": recordDraft.priority, "weight": recordDraft.weight, "port": recordDraft.port})
});
} else if (record.record_type === "NS" || record.record_type === "CNAME") {
let processedValue = recordDraft.value === '@' ? fqdn : recordDraft.value;
res = await fetch(`${PUBLIC_BACKEND_API_HOST}/api/v1/record`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({"record_id": record.id, "is_active": recordDraft.is_active, "name": processedName, "value": processedValue, "ttl": recordDraft.ttl, "comment": recordDraft.comment, "record_type": record.record_type, "subdomain_id": page.params.id })
});
} else {
res = await fetch(`${PUBLIC_BACKEND_API_HOST}/api/v1/record`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({"record_id": record.id, "is_active": recordDraft.is_active, "name": processedName, "value": recordDraft.value, "ttl": recordDraft.ttl, "comment": recordDraft.comment, "record_type": record.record_type, "subdomain_id": page.params.id })
});
}
let data = await res.json();
if (!res.ok) {
errorMessage = data?.msg || "Something went wrong";
console.log(errorMessage);
return;
}
records = handleUiRecordUpdates(records, data);
alert("Record successfully updated!")
} catch (err: any) {
errorMessage = err?.msg || "Network error";
console.log(errorMessage);
}
}
async function handleDeleteRecord(event: MouseEvent & { currentTarget: EventTarget & HTMLButtonElement; }) {
try {
let res = await fetch(`${PUBLIC_BACKEND_API_HOST}/api/v1/record`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({"record_id": record.id })});
if (!res.ok) {
errorMessage = (await res.json()).msg || "Something went wrong";
console.log(errorMessage);
return;
}
records.splice(i, 1);
isEdit = false;
alert("Record successfully deleted");
} catch (err: any) {
errorMessage = err?.msg || "Network error";
console.log(errorMessage);
}
}
</script>
<tr
class={isEdit
? "bg-base-300"
:
`${
i % 2 === 0 ? '[&>td]:bg-base-100' : '[&>td]:bg-base-200'
}`
}
>
<td class="max-w-25 w-25">
<span class="badge badge-outline px-2">{record.record_type}</span>
</td>
<td class="font-mono truncate">{record.name || '@'}</td>
<td class="max-w-40 font-mono truncate">{record.value}</td>
<td class="w-10 truncate hidden lg:table-cell">{record.ttl}</td>
<td class="max-w-40 truncate hidden lg:table-cell">{record.comment || '-'}</td>
<td class="w-0 pr-1 hidden lg:table-cell">
{#if record.is_active}
<span class="badge badge-success p-2 w-16">Active</span>
{:else}
<span class="badge badge-error p-2 w-16">Inactive</span>
{/if}
</td>
<td class="w-0 p-0 px-4">
{#if isEdit}
<button class="btn btn-xs tooltip rounded-md p-0 w-7 h-7" data-tip="Discard changes" onclick={triggerMenu}>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /></svg>
</button>
{:else}
<button class="btn btn-xs tooltip rounded-md p-0 w-7 h-7" data-tip="Edit" onclick={triggerMenu}>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"><path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10" /></svg>
</button>
{/if}
</td>
</tr>
{#if isEdit}
<tr class="bg-base-200">
<td colspan="99" class="rounded">
<form onsubmit={handleUpdateSubmit}>
<div class="flex flex-col sm:flex-row justify-center">
<div class="m-2 flex-1">
<label class="form-control flex flex-col">
<label class="fieldset-legend" for="record-name">Name</label>
<input class="input validator w-full" id="record-name" bind:value={recordDraft.name} placeholder={getNamePlaceholderText(record.record_type)} required/>
</label>
<div class="align-right validator-hint mt-1">{error}</div>
</div>
{#if record.record_type === 'MX'}
<div class="m-2 sm:w-20">
<label class="form-control flex flex-col">
<div class="flex flex-row items-center">
<label class="fieldset-legend mr-1" for="record-priority">Priority</label>
<div class="tooltip tooltip-info mt-1" data-tip="This field is useful if you have multiple mailservers. The server with the lower priority is tried first. If it fails to receive mail, the one with the next lowest priority is tried. Set the same priority values for for load-balancing.">
<div role="alert" class="alert m-0 p-0 gap-2 flex flex-column justify-center ml-auto ">
<svg class="stroke-primary-content h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
</div>
</div>
</div>
<input id="record-priority" type="number" class="input input-bordered" bind:value={recordDraft.priority} placeholder="10" required/>
</label>
<div class="align-right validator-hint mt-1">{error}</div>
</div>
{/if}
{#if record.record_type === 'SRV'}
<div class="m-2 sm:w-20">
<label class="form-control">
<div class="flex flex-row items-center">
<label class="fieldset-legend mr-1" for="record-priority">Priority</label>
<div class="tooltip tooltip-info mt-1" data-tip="This field is useful if you have multiple servers. The server with the lower priority is prioritized for traffic.">
<div role="alert" class="alert m-0 p-0 gap-2 flex flex-column justify-center ml-auto ">
<svg class="stroke-primary-content h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
</div>
</div>
</div>
<input id="record-priority" type="number" class="input validator" bind:value={recordDraft.priority} placeholder="10" required/>
</label>
<div class="align-right validator-hint mt-1">{error}</div>
</div>
<div class="m-2 sm:w-20">
<label class="form-control">
<div class="flex flex-row items-center">
<label class="fieldset-legend mr-1" for="record-weight">Weight</label>
<div class="tooltip tooltip-info mt-1" data-tip="This field serves a similar purpose as the Priority, except it is looked at second. So priority takes precedence.">
<div role="alert" class="alert m-0 p-0 gap-2 flex flex-column justify-center ml-auto ">
<svg class="stroke-primary-content h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
</div>
</div>
</div>
<input id="record-weight" type="number" class="input validator" bind:value={recordDraft.weight} placeholder="5" required/>
</label>
<div class="align-right validator-hint mt-1">{error}</div>
</div>
<div class="m-2 sm:w-20">
<label class="form-control">
<label class="fieldset-legend" for="record-port">Port</label>
<input id="record-port" type="number" class="input validator" bind:value={recordDraft.port} placeholder="8080" required/>
</label>
<div class="align-right validator-hint mt-1">{error}</div>
</div>
{/if}
<div class="m-2 flex-1">
<label class="form-control flex flex-col">
<label class="fieldset-legend" for="record-value">{getValueTitleText(record.record_type)}</label>
<input class="input validator w-full" id="record-value" bind:value={recordDraft.value} placeholder={getValuePlaceholderText(record.record_type)} required/>
</label>
<div class="align-right validator-hint mt-1">{error}</div>
</div>
</div>
<div class="flex flex-col sm:flex-row">
<label class="m-2 form-control flex flex-1 flex-col">
<div class="flex flex-row">
<label class="fieldset-legend" for="record-comment">Comment</label>
<div class="py-2 -mb-1 pl-1 text-primary-content/50">(optional)</div>
</div>
<div class="flex flex-row justify-center items-center">
<input class="input validator w-full mr-auto" id="record-comment" bind:value={recordDraft.comment} placeholder="This is my favorite DNS record!"/>
</div>
</label>
<div class="m-2 w-fill sm:w-30">
<label class="form-control flex flex-col">
<div class="flex flex-row items-center">
<label class="fieldset-legend mr-1" for="record-ttl">TTL</label>
<div class="tooltip tooltip-info mt-1" data-tip="TTL or 'Time To Live' controls how long the record is allowed to be cached on different devices. Set a lower number if you expect your record to change value very often.">
<div role="alert" class="alert m-0 p-0 gap-2 flex flex-column justify-center ml-auto ">
<svg class="stroke-primary-content h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
</div>
</div>
</div>
<input type="number" class="input input-bordered" id="record-ttl" bind:value={recordDraft.ttl} placeholder="300" required/>
</label>
<div class="align-right validator-hint mt-1">{error}</div>
</div>
</div>
<div class="flex flex-col sm:flex-row mx-2 sm:m-0 justify-center items-center mb-0">
<label class="label my-2 sm:mr-auto sm:ml-2">
Activate / deactivate record
<input
class="toggle toggle-xl sm:toggle-md border-error text-error-text bg-error checked:border-success checked:bg-success checked:text-success-text"
type="checkbox"
bind:checked={recordDraft.is_active}
/>
</label>
<button class="btn btn-error m-2 p-0 w-full sm:w-30" type="button" onclick={handleDeleteRecord}>Delete record</button>
<button class="btn btn-primary m-2 p-0 w-full sm:w-30" type="submit">Save</button>
</div>
<div class="divider m-0"></div>
<RecordExplanation recordType={recordDraft.record_type} name={recordDraft.name} value={recordDraft.value} displayName={getDisplayName(recordDraft.record_type, recordDraft.name)} displayValue={getDisplayValue(recordDraft.record_type, recordDraft.value, fqdn)} {fqdn}/>
{#if errorMessage}
<div class="mt-3 h-12 flex items-center">
<ErrorPopup {errorMessage} duration={10000} />
</div>
{/if}
</form>
</td>
</tr>
{/if}

View file

@ -0,0 +1,108 @@
<script>
let { recordType, name, value, displayName, displayValue, fqdn } = $props();
</script>
<div class="text m-2 mr-auto max-w-fit">
{#if recordType === 'A' || recordType === 'AAAA'}
<code class="bg-base-300 text-accent">
{#if name}
{displayName}
{:else}
<code class="text-primary-content/70">{displayName}</code>
{/if}{fqdn}</code>
points to
<code class="bg-base-300 text-accent">
{#if value}
{displayValue}
{:else}
<code class="text-primary-content/70">{displayValue}</code>
{/if}</code>
{:else if recordType === 'CNAME'}
<code class="bg-base-300 text-accent">
{#if name}
{displayName}
{:else}
<code class="text-primary-content/70">{displayName}</code>
{/if}{fqdn}</code>
redirects to
<code class="bg-base-300 text-accent">
{#if value}
{displayValue}
{:else}
<code class="text-primary-content/70">{displayValue}</code>
{/if}</code>
{:else if recordType === 'NS'}
<code class="bg-base-300 text-accent">
{#if name}
{displayName}
{:else}
<code class="text-primary-content/70">{displayName}</code>
{/if}{fqdn}</code>
has the nameserver
<code class="bg-base-300">
{#if value}
{displayValue}
{:else}
<code class="text-primary-content/70">{displayValue}</code>
{/if}</code>
{:else if recordType === 'CNAME'}
<code class="bg-base-300 text-accent">
{#if name}
{displayName}
{:else}
<code class="text-primary-content/70">{displayName}</code>
{/if}{fqdn}</code>
redirects to
<code class="bg-base-300">
{#if value}
{displayValue}
{:else}
<code class="text-primary-content/70">{displayValue}</code>
{/if}</code>
{:else if recordType === 'MX'}
<code class="bg-base-300 text-accent">
{#if name}
{displayName}
{:else}
<code class="text-primary-content/70">{displayName}</code>
{/if}{fqdn}</code>
has the mailserver
<code class="bg-base-300">
{#if value}
{displayValue}
{:else}
<code class="text-primary-content/70">{displayValue}</code>
{/if}</code>
{:else if recordType === 'SRV'}
A service of
<code class="bg-base-300 text-accent">
{#if name}
{displayName}
{:else}
<code class="text-primary-content/70">{displayName}</code>
{/if}{fqdn}</code>
is hosted at
<code class="bg-base-300">
{#if value}
{displayValue}
{:else}
<code class="text-primary-content/70">{displayValue}</code>
{/if}</code>
{:else if recordType === 'TXT'}
<code class="bg-base-300 text-accent">
{#if name}
{displayName}
{:else}
<code class="text-primary-content/70">{displayName}</code>
{/if}{fqdn}</code>
has the content
<code class="bg-base-300">
{#if value}
{displayValue}
{:else}
<code class="text-primary-content/70">{displayValue}</code>
{/if}</code>
{:else}
Unknown record type
{/if}
</div>

35
src/lib/SideMenu.svelte Normal file
View file

@ -0,0 +1,35 @@
<script lang="ts">
import { domains } from "./domains.svelte";
let errorMessage: string | null = $state(null);
</script>
<aside class="bg-base-200">
<ul class="menu overflow-y-auto h-full w-0 sm:w-60 hidden lg:block max-h-full scroll">
<li>
<a href="/dashboard/register-domain">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
Register a new domain
</a>
</li>
{#if !domains.loadingSubdomains}
<div class="divider m-0"></div>
<li>
<details open>
<summary>Manage your DNS records</summary>
<ul>
{#each domains.subdomains as sub}
<li><a href="/dashboard/{sub.id}">
{sub.name}.{sub.domain}
</a></li>
{/each}
</ul>
</details>
</li>
{:else if domains.loadingFailed}
<li class="bg-error rounded"><a class="text-error-content" href="/logout">Loading your domains failed. Please refresh the page or try logging out and back in</a></li>
{/if}
</ul>
</aside>

21
src/lib/api.ts Normal file
View file

@ -0,0 +1,21 @@
import { goto } from "$app/navigation";
import { setUserLoggedOut } from "./auth.svelte.ts";
export async function get_request<T = any>(url: string): Promise<T> {
try {
let res: Response = await fetch(url, {
credentials: "include",
});
if (res.status === 401) {
throw new Error("Unauthorized");
}
return res.json() as Promise<T>;
} catch (err) {
console.log(err);
setUserLoggedOut();
goto("/login");
throw err;
}
}

View file

@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="107"
height="128"
viewBox="0 0 107 128"
>
<title>svelte-logo</title><path
d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116"
style="fill: #ff3e00"
/><path
d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328"
style="fill: #fff"
/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

40
src/lib/auth.svelte.ts Normal file
View file

@ -0,0 +1,40 @@
import { PUBLIC_BACKEND_API_HOST } from "$env/static/public";
interface Auth {
isAuthenticated: boolean;
isMfaEnabled?: boolean;
userEmail?: string;
}
export let auth = $state<Auth>({
isAuthenticated: false,
});
// Call upon app startup. Makes a GET request to /user/me to check if the session token is present/valid
export async function initUserAuthStatus() {
try {
const res = await fetch(PUBLIC_BACKEND_API_HOST + "/api/v1/user/me", {
method: "GET",
});
if (res.status === 401) {
setUserLoggedOut();
} else if (res.status === 200) {
const data = await res.json();
auth.isAuthenticated = true;
auth.isMfaEnabled = data.mfa_enabled ?? false;
auth.userEmail = data.email ?? "error-email@example.com";
} else {
console.log(`Unexpected response from server: ${res.status}`);
}
} catch (error) {
console.log("unexpected error: ", error);
}
}
export function setUserLoggedOut() {
auth.isAuthenticated = false;
auth.isMfaEnabled = undefined;
auth.userEmail = undefined;
}

82
src/lib/domains.svelte.ts Normal file
View file

@ -0,0 +1,82 @@
import { PUBLIC_BACKEND_API_HOST } from "$env/static/public";
export type Subdomain = {
id: string;
domain: string;
name: string;
};
export type BaseDomain = {
id: string;
domain: string;
};
interface Domains {
loadingBaseDomains: boolean;
loadingSubdomains: boolean;
subdomains: Subdomain[];
subdomainsFromId: Record<string, Subdomain>,
baseDomains: BaseDomain[];
loadingFailed: boolean;
}
export let domains = $state<Domains>({
loadingBaseDomains: true,
loadingSubdomains: true,
subdomains: [],
subdomainsFromId: {},
baseDomains: [],
loadingFailed: false,
});
export async function clearSubdomains() {
domains.subdomains = [];
domains.loadingFailed = false;
domains.loadingSubdomains = true;
}
export async function getSubdomains() {
try {
const res = await fetch(
`${PUBLIC_BACKEND_API_HOST}/api/v1/subdomain`
);
const data = await res.json();
if (!res.ok) {
throw new Error(data?.msg || "Failed to load subdomains");
}
domains.subdomains = data
.slice()
.sort((a: { id: string; }, b: { id: any; }) => a.id.localeCompare(b.id));
for (const sub of domains.subdomains) {
domains.subdomainsFromId[sub.id] = sub;
}
} catch (err) {
console.error(err);
domains.loadingFailed = true;
} finally {
domains.loadingSubdomains = false;
}
};
export async function getBaseDomains() {
try {
const res = await fetch(
`${PUBLIC_BACKEND_API_HOST}/api/v1/subdomain/domains`
);
const data = await res.json();
if (!res.ok) {
throw new Error(data?.msg || "Failed to load base domains");
}
domains.baseDomains = data
.slice()
.sort((a: { id: string; }, b: { id: any; }) => a.id.localeCompare(b.id));
} catch (err) {
console.error(err);
domains.loadingFailed = true;
} finally {
domains.loadingBaseDomains = false;
}
};

1
src/lib/index.ts Normal file
View file

@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

175
src/lib/records.svelte.ts Normal file
View file

@ -0,0 +1,175 @@
import { PUBLIC_BACKEND_API_HOST } from '$env/static/public';
export interface DnsRecord {
id: string;
name: string;
record_type: string;
value: string;
ttl: number;
comment: string;
is_active: boolean;
priority?: number;
weight?: number;
port?: number;
}
export interface RecordsResponse {
msg: string;
records: DnsRecord[];
}
export async function fetchRecords(
subdomainId: string
): Promise<RecordsResponse> {
const res = await fetch(
`${PUBLIC_BACKEND_API_HOST}/api/v1/record/${subdomainId}`
);
if (!res.ok) {
throw new Error('Failed to fetch records');
}
return res.json();
}
export function getValueTitleText(type: string) {
switch (type) {
case 'A': return 'IPv4 address'
case 'AAAA': return 'IPv6 address'
case 'CNAME': return 'Target'
case 'NS': return 'Nameserver'
case 'MX': return 'Mailserver'
case 'SRV': return 'Target'
case 'TXT': return 'Content'
}
}
export function getNamePlaceholderText(type: string) {
switch (type) {
case 'A': return 'example'
case 'AAAA': return 'example'
case 'CNAME': return 'www'
case 'NS': return 'example'
case 'MX': return 'example'
case 'SRV': return '_service._proto'
case 'TXT': return 'example'
}
}
export function getValuePlaceholderText(type: string) {
switch (type) {
case 'A': return '198.51.100.51'
case 'AAAA': return 'fd40:7d1d:b637::'
case 'CNAME': return 'example.com'
case 'NS': return 'ns.example.com'
case 'MX': return 'mx.example.com'
case 'SRV': return 'example.com'
case 'TXT': return 'v=spf1 mx -all'
}
}
export function getDisplayName(type: string, name: string) {
if (!name) {
return `${getNamePlaceholderText(type)}.`;
}
if (name === "@") {
return '';
} else {
return `${name}.`
}
}
export function getDisplayValue(type: string, value: string, fqdn: string) {
if (!value) {
return `${getValuePlaceholderText(type)}`;
}
if (value === "@" && (type === 'MX' || type === 'CNAME' || type === 'NS')) {
return fqdn;
}
return value;
}
export function isRecordValid(
recordType: string,
name: string,
value: string,
priority: number,
weight: number,
port: number,
comment: string,
ttl: number
): string {
if (comment.length >= 255) {
return 'The comment length must not exceed 255 characters'
}
if (ttl < 30 || ttl > 2147483647) {
return 'The TTL must not be below 30 and above 2147483647'
}
if (!name.match(/^[a-zA-Z0-9-_.]+$/) && name !== '@') {
return 'Invalid record name';
}
switch (recordType) {
case 'A':
if (!value.match(/^(?:\d{1,3}\.){3}\d{1,3}$/)) {
return 'Invalid IPv4 address';
}
break;
case 'AAAA':
if (!value.match(/^[0-9a-fA-F:]+$/)) {
return 'Invalid IPv6 address';
}
break;
case 'CNAME':
case 'NS':
if (!value.match(/^[a-zA-Z0-9.-]+$/) && name === '@') {
return 'Target must be a fully qualified domain name';
}
break;
case 'MX':
if (priority < 0) {
return 'MX records require a non-negative priority';
}
break;
case 'SRV':
if (priority < 0 || weight < 0 || port < 0 ||
priority > 65535 || weight > 65535 || port > 65535
) {
return 'Priority, weight, and port must be in the range of 0-65535';
}
break;
case 'TXT':
if (value.length === 0 || value.length > 255) {
return 'The content of the TXT record must not exceed 255 characters';
}
break;
}
return '';
}
// Makes sure to update/replace records which have been implicitly updated by either a TTL change or have been created
// Find all records with the same name and type, delete them, insert the records from the API response
export function handleUiRecordUpdates(oldRecords: DnsRecord[], data: any): DnsRecord[] {
let records = oldRecords.filter((rec) => rec.record_type !== data.type || rec.name !== data.name);
for (let rec of data.records) {
let newRecord: DnsRecord = {
id: rec.id,
name: data.name,
record_type: data.type,
value: rec.value,
ttl: data.ttl,
comment: rec.comment,
is_active: rec.is_active,
priority: rec.priority,
weight: rec.weight,
port: rec.port,
};
records.push(newRecord);
}
return records.sort((a, b) => (a.id > b.id ? -1 : 1));
}