feat: improve ux and security
This commit is contained in:
22
app/components/ConfirmModal.vue
Normal file
22
app/components/ConfirmModal.vue
Normal 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>
|
||||||
@@ -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(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,12 +7,16 @@ 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) => {
|
||||||
const params = await getValidatedRouterParams(event, paramsSchema.parse);
|
const params = await getValidatedRouterParams(event, paramsSchema.parse);
|
||||||
|
|||||||
@@ -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(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 => {
|
||||||
|
|||||||
Reference in New Issue
Block a user