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 @@
+
+
+
+
+
+ {{ description }}
+
+
+
+ Cancel
+ Delete
+
+
+
+
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 => {