diff --git a/app/components/ConfirmModal.vue b/app/components/ConfirmModal.vue new file mode 100644 index 0000000..f5f4e4b --- /dev/null +++ b/app/components/ConfirmModal.vue @@ -0,0 +1,22 @@ + + + diff --git a/app/components/LinkModal.vue b/app/components/LinkModal.vue index 5892d91..3bbb32e 100644 --- a/app/components/LinkModal.vue +++ b/app/components/LinkModal.vue @@ -8,7 +8,7 @@ const props = defineProps<{ const schema = z.object({ 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", }), disabled: z.boolean().optional(), }); diff --git a/app/pages/dashboard.vue b/app/pages/dashboard.vue index 73b285c..9e0b125 100644 --- a/app/pages/dashboard.vue +++ b/app/pages/dashboard.vue @@ -1,5 +1,5 @@ diff --git a/server/api/auth/sign-in.post.ts b/server/api/auth/sign-in.post.ts index 77ea2d0..d48dea7 100644 --- a/server/api/auth/sign-in.post.ts +++ b/server/api/auth/sign-in.post.ts @@ -1,3 +1,4 @@ +import { timingSafeEqual } from "node:crypto"; import { z } from "zod"; import { env } from "#server/env"; @@ -6,10 +7,44 @@ const bodySchema = z.object({ password: z.string(), }); +const attempts = new Map(); +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) => { + 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); - 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" }); } diff --git a/server/api/links/[id]/index.patch.ts b/server/api/links/[id]/index.patch.ts index 3634b81..22e2caa 100644 --- a/server/api/links/[id]/index.patch.ts +++ b/server/api/links/[id]/index.patch.ts @@ -7,12 +7,16 @@ const paramsSchema = z.object({ id: z.string().transform(Number), }); -const bodySchema = z.object({ - name: z.string().min(1).optional(), - path: z.string().min(1).optional(), - url: z.url().optional(), - disabled: z.boolean().optional(), -}); +const bodySchema = z + .object({ + name: z.string().min(1).optional(), + path: z.string().min(1).startsWith("/").optional(), + url: z.url().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) => { const params = await getValidatedRouterParams(event, paramsSchema.parse); diff --git a/server/api/links/index.post.ts b/server/api/links/index.post.ts index f8951da..c6e9931 100644 --- a/server/api/links/index.post.ts +++ b/server/api/links/index.post.ts @@ -4,7 +4,7 @@ import { z } from "zod"; const bodySchema = z.object({ name: z.string().min(1), - path: z.string().min(1), + path: z.string().min(1).startsWith("/"), url: z.url(), disabled: z.boolean().optional(), }); diff --git a/server/db/schema.ts b/server/db/schema.ts index 39c6f8f..8bb107d 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -4,6 +4,6 @@ export const links = sqliteTable("links", { id: integer("id").primaryKey({ autoIncrement: true }), name: text("name").notNull().unique(), path: text("path").notNull().unique(), - url: text("url").notNull().unique(), + url: text("url").notNull(), disabled: integer("disabled", { mode: "boolean" }).notNull().default(false), }); diff --git a/server/utils/links.ts b/server/utils/links.ts index 6d75124..9b2c33e 100644 --- a/server/utils/links.ts +++ b/server/utils/links.ts @@ -2,7 +2,7 @@ import { db } from "#server/db"; import * as tables from "#server/db/schema"; 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]; export const joinFields = (fields: string[]): string => {