mirror of
https://github.com/KevinMidboe/infra-map.git
synced 2025-10-29 17:40:28 +00:00
use axfr DNS request to get all zonal entries
This commit is contained in:
67
src/lib/components/forms/FormDNS.svelte
Normal file
67
src/lib/components/forms/FormDNS.svelte
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Input from '$lib/components/Input.svelte';
|
||||||
|
import TextSize from '$lib/icons/text-size.svelte';
|
||||||
|
import Quill from '$lib/icons/quill.svelte';
|
||||||
|
import Tag from '$lib/icons/tag.svelte';
|
||||||
|
import Dropdown from '../Dropdown.svelte';
|
||||||
|
|
||||||
|
const { close }: { close: () => void } = $props();
|
||||||
|
|
||||||
|
const dnsTypes = ['A', 'AAAA', 'CNAME', 'NS', 'SOA', 'MX', 'TXT', 'PTR'];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<form method="POST">
|
||||||
|
<div class="wrapper">
|
||||||
|
<Input label="Name" icon={TextSize} placeholder="Website name" required />
|
||||||
|
<Dropdown
|
||||||
|
placeholder="Record type"
|
||||||
|
label="Type"
|
||||||
|
required={true}
|
||||||
|
icon={Tag}
|
||||||
|
options={dnsTypes}
|
||||||
|
/>
|
||||||
|
<Input label="Content" icon={Quill} placeholder="IPv4 address" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<button on:click={close} aria-disabled="false" type="button" tabindex="0"
|
||||||
|
><span tabindex="-1">Cancel</span></button
|
||||||
|
>
|
||||||
|
<button class="affirmative" type="submit" tabindex="-1">
|
||||||
|
<span tabindex="-1">Add record</span>
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
form {
|
||||||
|
.wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
max-width: 100%;
|
||||||
|
height: 2.5rem;
|
||||||
|
width: auto;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
button {
|
||||||
|
flex: unset;
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(form .wrapper div) {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
8
src/lib/interfaces/DNS.ts
Normal file
8
src/lib/interfaces/DNS.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export interface Record {
|
||||||
|
name: string;
|
||||||
|
ttl: number;
|
||||||
|
class: string;
|
||||||
|
type: string;
|
||||||
|
data: string;
|
||||||
|
available?: boolean;
|
||||||
|
}
|
||||||
266
src/lib/utils/dns.ts
Normal file
266
src/lib/utils/dns.ts
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
import dns from 'dns';
|
||||||
|
import net from 'net';
|
||||||
|
|
||||||
|
let timeout = 0;
|
||||||
|
|
||||||
|
const axfrReqProloge =
|
||||||
|
'\x00\x00' + // Size
|
||||||
|
'\x00\x00' + // Transaction ID
|
||||||
|
'\x00\x20' + // Flags: Standard Query
|
||||||
|
'\x00\x01' + // Number of questions
|
||||||
|
'\x00\x00' + // Number of answers
|
||||||
|
'\x00\x00' + // Number of Authority RRs
|
||||||
|
'\x00\x00'; // Number of Additional RRs
|
||||||
|
|
||||||
|
const axfrReqEpiloge =
|
||||||
|
'\x00' + // End of name
|
||||||
|
'\x00\xfc' + // Type: AXFR
|
||||||
|
'\x00\x01'; // Class: IN
|
||||||
|
|
||||||
|
function inet_ntoa(num: number): string {
|
||||||
|
const nbuffer = new ArrayBuffer(4);
|
||||||
|
const ndv = new DataView(nbuffer);
|
||||||
|
ndv.setUint32(0, num);
|
||||||
|
|
||||||
|
const a: number[] = [];
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
a[i] = ndv.getUint8(i);
|
||||||
|
}
|
||||||
|
return a.join('.');
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DecompressedLabel {
|
||||||
|
len: number;
|
||||||
|
name: string;
|
||||||
|
next?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decompressLabel(data: Buffer, offset: number): DecompressedLabel {
|
||||||
|
const res: DecompressedLabel = { len: 0, name: '' };
|
||||||
|
let loffset = offset;
|
||||||
|
let tmpoff = 0;
|
||||||
|
|
||||||
|
while (data[loffset] !== 0x00) {
|
||||||
|
// Check for pointers
|
||||||
|
if ((data[loffset] & 0xc0) === 0xc0) {
|
||||||
|
const newoffset = data.readUInt16BE(loffset) & 0x3fff;
|
||||||
|
const label = decompressLabel(data, newoffset + 2);
|
||||||
|
res.name += label.name;
|
||||||
|
loffset += 1;
|
||||||
|
break;
|
||||||
|
} else {
|
||||||
|
// Normal label
|
||||||
|
tmpoff = loffset + 1;
|
||||||
|
res.name += data.toString('utf8', tmpoff, tmpoff + data[loffset]);
|
||||||
|
res.name += '.';
|
||||||
|
loffset += data[loffset] + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.next = loffset + 1;
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DNSResult {
|
||||||
|
questions: any[];
|
||||||
|
answers: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseResponse(response: Buffer, result: DNSResult): DNSResult | number {
|
||||||
|
let offset = 14;
|
||||||
|
let len = response.readUInt16BE(0);
|
||||||
|
|
||||||
|
if (response.length !== len + 2) return -1; // Invalid length
|
||||||
|
if ((response[4] & 0x80) !== 0x80) return -2; // Not a query response
|
||||||
|
if ((response[5] & 0x0f) !== 0) return -3; // Error code present
|
||||||
|
|
||||||
|
const questions = response.readUInt16BE(6);
|
||||||
|
const answers = response.readUInt16BE(8);
|
||||||
|
const authRRs = response.readUInt16BE(10);
|
||||||
|
const aditRRs = response.readUInt16BE(12);
|
||||||
|
|
||||||
|
// Parse queries
|
||||||
|
for (let x = 0; x < questions; x++) {
|
||||||
|
const entry = decompressLabel(response, offset);
|
||||||
|
result.questions.push({
|
||||||
|
name: entry.name,
|
||||||
|
type: 'AXFR'
|
||||||
|
});
|
||||||
|
offset = (entry.next ?? 0) + 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse answers
|
||||||
|
for (let x = 0; x < answers; x++) {
|
||||||
|
let entry: any = {};
|
||||||
|
entry = decompressLabel(response, offset);
|
||||||
|
offset = entry.next!;
|
||||||
|
entry.name = entry.name;
|
||||||
|
|
||||||
|
const type = response.readUInt16BE(offset);
|
||||||
|
const rclass = response.readUInt16BE(offset + 2);
|
||||||
|
entry.ttl = response.readUInt32BE(offset + 4);
|
||||||
|
const rlen = response.readUInt16BE(offset + 8);
|
||||||
|
|
||||||
|
// Skip non-INET
|
||||||
|
if (rclass !== 0x01) {
|
||||||
|
offset += rlen + 10;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 0x01: // A
|
||||||
|
entry.type = 'A';
|
||||||
|
entry.a = inet_ntoa(response.readUInt32BE(offset + 10));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 0x02: // NS
|
||||||
|
entry.type = 'NS';
|
||||||
|
entry.ns = decompressLabel(response, offset + 10).name;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 0x05: // CNAME
|
||||||
|
entry.type = 'CNAME';
|
||||||
|
entry.cname = decompressLabel(response, offset + 10).name;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 0x06: {
|
||||||
|
// SOA
|
||||||
|
entry.type = 'SOA';
|
||||||
|
let tentry = decompressLabel(response, offset + 10);
|
||||||
|
entry.dns = tentry.name;
|
||||||
|
tentry = decompressLabel(response, tentry.next!);
|
||||||
|
entry.mail = tentry.name;
|
||||||
|
entry.serial = response.readUInt32BE(tentry.next!);
|
||||||
|
entry.refreshInterval = response.readUInt32BE(tentry.next! + 4);
|
||||||
|
entry.retryInterval = response.readUInt32BE(tentry.next! + 8);
|
||||||
|
entry.expireLimit = response.readUInt32BE(tentry.next! + 12);
|
||||||
|
entry.minTTL = response.readUInt32BE(tentry.next! + 16);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 0x0f: // MX
|
||||||
|
entry.type = 'MX';
|
||||||
|
entry.pref = response.readUInt16BE(offset + 10);
|
||||||
|
entry.mx = decompressLabel(response, offset + 12).name;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 0x10: // TXT
|
||||||
|
entry.type = 'TXT';
|
||||||
|
len = response[offset + 10];
|
||||||
|
entry.txt = response.toString('utf8', offset + 11, offset + 11 + len);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 0x1c: {
|
||||||
|
// AAAA
|
||||||
|
entry.type = 'AAAA';
|
||||||
|
// const byteArr = new Uint8Array(response.slice(offset + 10, offset + 26));
|
||||||
|
// entry.aaaa = fromByteArray(byteArr).toString();
|
||||||
|
console.warn('unable to parse AAAA DNS records');
|
||||||
|
entry.aaaa = '::';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 0x63: // SPF
|
||||||
|
entry.type = 'SPF';
|
||||||
|
len = response[offset + 10];
|
||||||
|
entry.txt = response.toString('utf8', offset + 11, offset + 11 + len);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 0x21: // SRV
|
||||||
|
entry.type = 'SRV';
|
||||||
|
entry.priority = response.readUInt16BE(offset + 10);
|
||||||
|
entry.weight = response.readUInt16BE(offset + 12);
|
||||||
|
entry.port = response.readUInt16BE(offset + 14);
|
||||||
|
entry.target = decompressLabel(response, offset + 16).name;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
delete entry.len;
|
||||||
|
delete entry.next;
|
||||||
|
result.answers.push(entry);
|
||||||
|
offset += rlen + 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
(dns as any).resolveAxfrTimeout = (milis: number): void => {
|
||||||
|
timeout = milis;
|
||||||
|
};
|
||||||
|
|
||||||
|
(dns as any).resolveAxfr = (
|
||||||
|
server: string,
|
||||||
|
domain: string,
|
||||||
|
callback: (code: number, result: any) => void
|
||||||
|
): void => {
|
||||||
|
const buffers: Buffer[] = [];
|
||||||
|
const split = domain.split('.');
|
||||||
|
const results: DNSResult = { questions: [], answers: [] };
|
||||||
|
let responses: Buffer[] = [];
|
||||||
|
let len = 0;
|
||||||
|
let tlen = 0;
|
||||||
|
const [hostname, portStr] = server.split(':');
|
||||||
|
const port = Number(portStr) || 53;
|
||||||
|
|
||||||
|
// Build request
|
||||||
|
buffers.push(Buffer.from(axfrReqProloge, 'binary'));
|
||||||
|
split.forEach((elem) => {
|
||||||
|
const label = Buffer.from('\x00' + elem, 'utf8');
|
||||||
|
label.writeUInt8(elem.length, 0);
|
||||||
|
buffers.push(label);
|
||||||
|
});
|
||||||
|
buffers.push(Buffer.from(axfrReqEpiloge, 'binary'));
|
||||||
|
const buffer = Buffer.concat(buffers);
|
||||||
|
|
||||||
|
// Set size and transaction ID
|
||||||
|
buffer.writeUInt16BE(buffer.length - 2, 0);
|
||||||
|
buffer.writeUInt16BE(Math.floor(Math.random() * 65535 + 1), 2);
|
||||||
|
|
||||||
|
// Connect and send request
|
||||||
|
const socket = net.connect(port, hostname, () => {
|
||||||
|
socket.write(buffer.toString('binary'), 'binary');
|
||||||
|
socket.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (timeout) socket.setTimeout(timeout);
|
||||||
|
|
||||||
|
socket.on('data', (data: Buffer) => {
|
||||||
|
if (len === 0) len = data.readUInt16BE(0);
|
||||||
|
responses.push(data);
|
||||||
|
tlen += data.length;
|
||||||
|
|
||||||
|
if (tlen >= len + 2) {
|
||||||
|
const buf = Buffer.concat(responses, tlen);
|
||||||
|
const tmpBuf = buf.slice(0, len + 2);
|
||||||
|
const res = parseResponse(tmpBuf, results);
|
||||||
|
|
||||||
|
if (typeof res !== 'object') {
|
||||||
|
socket.destroy();
|
||||||
|
callback(res, 'Error on response');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tlen > len + 2) {
|
||||||
|
const buf = Buffer.concat(responses, tlen);
|
||||||
|
const tmpBuf = buf.slice(len + 2);
|
||||||
|
len = tmpBuf.readUInt16BE(0);
|
||||||
|
tlen = tmpBuf.length;
|
||||||
|
responses = [tmpBuf];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('timeout', () => {
|
||||||
|
socket.destroy();
|
||||||
|
callback(-5, 'Timeout');
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('end', () => {
|
||||||
|
callback(0, results);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('error', () => {
|
||||||
|
callback(-4, 'Error connecting');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default dns;
|
||||||
63
src/routes/dns/+page.server.ts
Normal file
63
src/routes/dns/+page.server.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { exec } from 'child_process';
|
||||||
|
import dns from '$lib/utils/dns';
|
||||||
|
import type { Record } from '$lib/interfaces/DNS';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
// Regex for IPv4 validation
|
||||||
|
const ipv4Regex = /^(25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)){3}$/;
|
||||||
|
|
||||||
|
function ping(data: string): Promise<boolean> {
|
||||||
|
if (!ipv4Regex.test(data)) return;
|
||||||
|
const ip = data;
|
||||||
|
|
||||||
|
console.log(`Starting ping for ip: ${ip}`);
|
||||||
|
const resolver = '10.0.0.72';
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
exec(
|
||||||
|
`ping -c 1 -t 1.5 -W 1 ${ip} > /dev/null 2>&1 && exit 0 || exit 1`,
|
||||||
|
(error, stdout, stderr) => {
|
||||||
|
if (error) {
|
||||||
|
console.error(`❌ Exec error: ${error.message}`);
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
if (stderr) {
|
||||||
|
console.error(`⚠️ Stderr: ${stderr}`);
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✅ Ping response received:', stdout);
|
||||||
|
resolve(true);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function axfrAsync(server: string, domain: string) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
dns.resolveAxfr(server, domain, (err, resp) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
resolve(resp?.answers);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async () => {
|
||||||
|
const server = '10.0.0.72';
|
||||||
|
const domain = 'schleppe';
|
||||||
|
const records = await axfrAsync(server, domain);
|
||||||
|
|
||||||
|
// console.log('records::', records);
|
||||||
|
const ARecords = records?.filter((r) => r?.type === 'A');
|
||||||
|
|
||||||
|
for (let i = 0; i < ARecords.length; i++) {
|
||||||
|
try {
|
||||||
|
// const a = await ping(ARecords[i].a);
|
||||||
|
records[i]['available'] = true;
|
||||||
|
} catch (_) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
records
|
||||||
|
};
|
||||||
|
};
|
||||||
163
src/routes/dns/+page.svelte
Normal file
163
src/routes/dns/+page.svelte
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Search from '$lib/icons/search.svelte';
|
||||||
|
import Input from '$lib/components/Input.svelte';
|
||||||
|
import Table from '$lib/components/Table.svelte';
|
||||||
|
import Dialog from '$lib/components/Dialog.svelte';
|
||||||
|
import FormDNS from '$lib/components/forms/FormDNS.svelte';
|
||||||
|
import type { Record } from '$lib/interfaces/DNS';
|
||||||
|
import type { PageData } from '../$types';
|
||||||
|
|
||||||
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
|
let recordsFilter = $state('');
|
||||||
|
let recordsSort = $state('type');
|
||||||
|
let open = $state(false);
|
||||||
|
|
||||||
|
const rawRecords: Record[] = data?.records || [];
|
||||||
|
let records = $derived(
|
||||||
|
rawRecords
|
||||||
|
?.filter(
|
||||||
|
(r: Record) =>
|
||||||
|
r.name?.toLowerCase()?.includes(recordsFilter) ||
|
||||||
|
r.data?.toLowerCase()?.includes(recordsFilter)
|
||||||
|
)
|
||||||
|
.sort((a, b) => (a?.[recordsSort] < b?.[recordsSort] ? -1 : 1))
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h1>DNS</h1>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
title="Zonal records"
|
||||||
|
description="schleppe colors are currently in stock. Overview of currently stocked filament."
|
||||||
|
columns={['Ping', 'Details', 'TTL']}
|
||||||
|
data={records}
|
||||||
|
footer="Last updated on ~some date~"
|
||||||
|
>
|
||||||
|
<div slot="actions" class="filament-table-inputs">
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
placeholder={`${records?.[0]?.name || 'record'}`}
|
||||||
|
icon={Search}
|
||||||
|
bind:value={recordsFilter}
|
||||||
|
label="Records filter"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="affirmative" on:click={() => (open = true)}><span>Add new</span></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<tbody slot="tbody">
|
||||||
|
{#each records as row (row)}
|
||||||
|
<tr>
|
||||||
|
<td>{row?.available === true ? 'up' : 'down'}</td>
|
||||||
|
<td class="info">
|
||||||
|
<div class="meta">
|
||||||
|
<span>name: {row.name}</span>
|
||||||
|
<span>type: {row.type}</span>
|
||||||
|
<span>addr: {row.a}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>{row.ttl}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<Dialog
|
||||||
|
title="Add DNS record"
|
||||||
|
description="You can select anything deployed in <b>Belgium (europe-west1) datacenter</b> and create an internal connection with your service."
|
||||||
|
close={() => (open = false)}
|
||||||
|
>
|
||||||
|
<FormDNS close={() => (open = false)} />
|
||||||
|
</Dialog>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.section-element {
|
||||||
|
.icon {
|
||||||
|
display: inline-block;
|
||||||
|
--size: 2rem;
|
||||||
|
height: var(--size);
|
||||||
|
width: var(--size);
|
||||||
|
padding-right: 0.5rem;
|
||||||
|
|
||||||
|
&.spin {
|
||||||
|
@keyframes rotate {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
animation: rotate 6s linear infinite;
|
||||||
|
transform-origin: calc((var(--size) / 2) - 2px) calc(var(--size) / 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
span {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.filament-table-inputs {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
> div {
|
||||||
|
max-width: 450px;
|
||||||
|
}
|
||||||
|
|
||||||
|
> button {
|
||||||
|
flex: unset;
|
||||||
|
height: 2.6rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* filament table */
|
||||||
|
tr td {
|
||||||
|
&:first-of-type {
|
||||||
|
}
|
||||||
|
|
||||||
|
&.info {
|
||||||
|
display: table-cell;
|
||||||
|
vertical-align: middle;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
width: 100%;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
margin-bottom: 0.45em;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 300;
|
||||||
|
color: var(--theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.3rem;
|
||||||
|
flex-direction: column;
|
||||||
|
opacity: 0.6;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.color {
|
||||||
|
--size: 4rem;
|
||||||
|
display: block;
|
||||||
|
width: var(--size);
|
||||||
|
height: var(--size);
|
||||||
|
border-radius: var(--border-radius, 1rem);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user