feat: improve ux and security

This commit is contained in:
2026-03-25 17:30:49 +01:00
parent fedb0ae8db
commit 9c61d7561a
8 changed files with 80 additions and 15 deletions

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
defineProps<{
title: string;
description?: string;
}>();
const emit = defineEmits<{ close: [confirmed: boolean] }>();
</script>
<template>
<UModal :title="title">
<template #body>
<p v-if="description" class="text-sm text-muted">{{ description }}</p>
</template>
<template #footer>
<div class="flex gap-2 justify-end w-full">
<UButton variant="ghost" color="neutral" @click="emit('close', false)">Cancel</UButton>
<UButton color="error" @click="emit('close', true)">Delete</UButton>
</div>
</template>
</UModal>
</template>

View File

@@ -8,7 +8,7 @@ const props = defineProps<{
const schema = z.object({ const schema = z.object({
name: z.string({ error: "Required" }), name: z.string({ error: "Required" }),
path: z.string({ error: "Required" }), path: z.string({ error: "Required" }).startsWith("/", "Must start with /"),
url: z.url({ error: (err) => err.code === "invalid_type" ? "Required" : "Invalid format", }), url: z.url({ error: (err) => err.code === "invalid_type" ? "Required" : "Invalid format", }),
disabled: z.boolean().optional(), disabled: z.boolean().optional(),
}); });

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { LazyLinkModal } from "#components"; import { LazyLinkModal, LazyConfirmModal } from "#components";
definePageMeta({ middleware: "auth" }); definePageMeta({ middleware: "auth" });
@@ -37,14 +37,18 @@ const openModal = async (link: Link | null) => {
} }
const deleteLink = async (link: Link) => { const deleteLink = async (link: Link) => {
const modal = overlay.create(LazyConfirmModal, { destroyOnClose: true });
const instance = modal.open({ title: "Delete link", description: `Are you sure you want to delete "${link.name}"?` });
if (!await instance.result) return;
try { try {
await $fetch(`/api/links/${link.id}`, { method: "DELETE" }); await $fetch(`/api/links/${link.id}`, { method: "DELETE" });
toast.add({ title: "Link deleted", color: "success" }); toast.add({ title: "Link deleted", color: "success" });
await refresh(); await refresh();
} catch { } catch (error) {
toast.add({ title: "Failed to delete link", color: "error" }); toast.add({ title: getApiError(error), color: "error" });
}
} }
};
</script> </script>

View File

@@ -1,3 +1,4 @@
import { timingSafeEqual } from "node:crypto";
import { z } from "zod"; import { z } from "zod";
import { env } from "#server/env"; import { env } from "#server/env";
@@ -6,10 +7,44 @@ const bodySchema = z.object({
password: z.string(), password: z.string(),
}); });
const attempts = new Map<string, { count: number; resetAt: number }>();
const MAX_ATTEMPTS = 10;
const WINDOW_MS = 60_000;
const isRateLimited = (ip: string): boolean => {
const now = Date.now();
const entry = attempts.get(ip);
if (!entry || now > entry.resetAt) {
attempts.set(ip, { count: 1, resetAt: now + WINDOW_MS });
return false;
}
if (entry.count >= MAX_ATTEMPTS) return true;
entry.count++;
return false;
};
const safeEqual = (a: string, b: string): boolean => {
const aBuf = Buffer.from(a);
const bBuf = Buffer.from(b);
if (aBuf.length !== bBuf.length) {
timingSafeEqual(aBuf, aBuf);
return false;
}
return timingSafeEqual(aBuf, bBuf);
};
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const ip = getRequestIP(event) ?? "unknown";
if (isRateLimited(ip)) {
throw createError({ statusCode: 429, message: "Too many attempts, try again later" });
}
const body = await readValidatedBody(event, bodySchema.parse); const body = await readValidatedBody(event, bodySchema.parse);
if (body.username !== env.ADMIN_USERNAME || body.password !== env.ADMIN_PASSWORD) { const valid = safeEqual(body.username, env.ADMIN_USERNAME) && safeEqual(body.password, env.ADMIN_PASSWORD);
if (!valid) {
throw createError({ statusCode: 401, message: "Invalid credentials" }); throw createError({ statusCode: 401, message: "Invalid credentials" });
} }

View File

@@ -7,11 +7,15 @@ const paramsSchema = z.object({
id: z.string().transform(Number), id: z.string().transform(Number),
}); });
const bodySchema = z.object({ const bodySchema = z
.object({
name: z.string().min(1).optional(), name: z.string().min(1).optional(),
path: z.string().min(1).optional(), path: z.string().min(1).startsWith("/").optional(),
url: z.url().optional(), url: z.url().optional(),
disabled: z.boolean().optional(), disabled: z.boolean().optional(),
})
.refine((data) => Object.values(data).some((v) => v !== undefined), {
message: "At least one field must be provided",
}); });
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {

View File

@@ -4,7 +4,7 @@ import { z } from "zod";
const bodySchema = z.object({ const bodySchema = z.object({
name: z.string().min(1), name: z.string().min(1),
path: z.string().min(1), path: z.string().min(1).startsWith("/"),
url: z.url(), url: z.url(),
disabled: z.boolean().optional(), disabled: z.boolean().optional(),
}); });

View File

@@ -4,6 +4,6 @@ export const links = sqliteTable("links", {
id: integer("id").primaryKey({ autoIncrement: true }), id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull().unique(), name: text("name").notNull().unique(),
path: text("path").notNull().unique(), path: text("path").notNull().unique(),
url: text("url").notNull().unique(), url: text("url").notNull(),
disabled: integer("disabled", { mode: "boolean" }).notNull().default(false), disabled: integer("disabled", { mode: "boolean" }).notNull().default(false),
}); });

View File

@@ -2,7 +2,7 @@ import { db } from "#server/db";
import * as tables from "#server/db/schema"; import * as tables from "#server/db/schema";
import { and, eq, ne } from "drizzle-orm"; import { and, eq, ne } from "drizzle-orm";
const UNIQUE_FIELDS = ["name", "path", "url"] as const; const UNIQUE_FIELDS = ["name", "path"] as const;
type UniqueField = (typeof UNIQUE_FIELDS)[number]; type UniqueField = (typeof UNIQUE_FIELDS)[number];
export const joinFields = (fields: string[]): string => { export const joinFields = (fields: string[]): string => {