Compare commits
21 Commits
9703fa77e2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f34730b609 | |||
| e72f7aa839 | |||
| e858825749 | |||
| 90a81f353d | |||
| 8f4be48fd1 | |||
| ffba95a411 | |||
| 5d5d691bab | |||
| b763eb70db | |||
| 783ee1b334 | |||
| 73c6bdb89b | |||
| 1d5fb09eb0 | |||
| 561fb56419 | |||
| 9c61d7561a | |||
| fedb0ae8db | |||
| bc9a95a7c8 | |||
| 1d893719ef | |||
| 5ca59b205e | |||
| a935d61531 | |||
| 9ec4c5319c | |||
| 0c8677f514 | |||
| 3be2034a49 |
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
.git
|
||||
.nuxt
|
||||
.output
|
||||
node_modules
|
||||
*.db
|
||||
.env*
|
||||
sqlite.db
|
||||
6
.env.example
Normal file
6
.env.example
Normal file
@@ -0,0 +1,6 @@
|
||||
DATABASE_URL=sqlite.db
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=strong_password
|
||||
REDIRECT_DOMAIN=pihka.al
|
||||
|
||||
NUXT_SESSION_PASSWORD=strong_password
|
||||
34
.gitea/workflows/deploy.yml
Normal file
34
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,34 @@
|
||||
name: Build and Push Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Gitea Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.pihkaal.me
|
||||
username: ${{ secrets.REGISTRY_USERNAME }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: git.pihkaal.me/pihkaal/pihka-al:latest
|
||||
cache-from: type=registry,ref=git.pihkaal.me/pihkaal/pihka-al:cache
|
||||
cache-to: type=registry,ref=git.pihkaal.me/pihkaal/pihka-al:cache,mode=max
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -13,6 +13,9 @@ node_modules
|
||||
logs
|
||||
*.log
|
||||
|
||||
# Database
|
||||
*.db
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
.fleet
|
||||
|
||||
20
Dockerfile
Normal file
20
Dockerfile
Normal file
@@ -0,0 +1,20 @@
|
||||
FROM node:22-slim AS base
|
||||
RUN corepack enable
|
||||
|
||||
FROM base AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
FROM base AS build
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
RUN pnpm build
|
||||
|
||||
FROM base AS runtime
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/.output ./.output
|
||||
COPY --from=build /app/server/db/migrations ./server/db/migrations
|
||||
EXPOSE 3000
|
||||
CMD ["node", ".output/server/index.mjs"]
|
||||
39
README.md
39
README.md
@@ -1 +1,38 @@
|
||||
# pihka.al
|
||||
<div align="center">
|
||||
<h1>pihka.al</h1>
|
||||
<img src="docs/demo.gif" alt="demo" />
|
||||
</div>
|
||||
|
||||
Personal dashboard for managing my [pihka.al](https://pihka.al) domain. I'm building it for my own use, it's not designed as a generic tool and not meant to be reused as-is.
|
||||
|
||||
## What it does
|
||||
|
||||
- Manage short URLs served from [pihka.al](https://pihka.al)
|
||||
|
||||
## Next features
|
||||
|
||||
- **API**: Create an API token that can be used to manage short URLs from anywere
|
||||
- **File management**: Upload and manage files directly in the dasboard
|
||||
- **Text service**: Pastebin-like service, including burner messages
|
||||
|
||||
## Stack
|
||||
|
||||
- [Nuxt 4](https://nuxt.com) + [Nuxt UI](https://ui.nuxt.com)
|
||||
- [SQLite](https://sqlite.org) via [Drizzle ORM](https://orm.drizzle.team)
|
||||
|
||||
## Running locally
|
||||
|
||||
```sh
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
```
|
||||
# .env.example
|
||||
DATABASE_URL=sqlite.db
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=strong_password
|
||||
REDIRECT_DOMAIN=pihka.al
|
||||
|
||||
NUXT_SESSION_PASSWORD=strong_password
|
||||
```
|
||||
|
||||
10
app/app.config.ts
Normal file
10
app/app.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export default defineAppConfig({
|
||||
ui: {
|
||||
colors: {
|
||||
primary: "neutral",
|
||||
neutral: "zinc",
|
||||
success: "emerald",
|
||||
error: "rose",
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1,7 +1,5 @@
|
||||
<template>
|
||||
<UApp>
|
||||
<UMain>
|
||||
<NuxtPage />
|
||||
</UMain>
|
||||
<NuxtPage />
|
||||
</UApp>
|
||||
</template>
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
@import "tailwindcss";
|
||||
@import "@nuxt/ui";
|
||||
|
||||
:root {
|
||||
--ui-radius: 0rem;
|
||||
}
|
||||
|
||||
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" icon="i-lucide-trash-2" @click="emit('close', true)">Delete</UButton>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
81
app/components/LinkModal.vue
Normal file
81
app/components/LinkModal.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<script setup lang="ts">
|
||||
import { z } from "zod";
|
||||
import type { FormSubmitEvent } from "@nuxt/ui";
|
||||
|
||||
const props = defineProps<{
|
||||
link: Link | null;
|
||||
}>();
|
||||
|
||||
const schema = z.object({
|
||||
name: 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(),
|
||||
});
|
||||
|
||||
type Schema = z.output<typeof schema>;
|
||||
|
||||
const emit = defineEmits<{ close: [value: boolean] }>();
|
||||
|
||||
const toast = useToast();
|
||||
const submitting = ref(false);
|
||||
|
||||
const state = reactive<Partial<Schema>>({
|
||||
name: props.link?.name,
|
||||
path: props.link?.path,
|
||||
url: props.link?.url,
|
||||
disabled: props.link?.disabled ?? false,
|
||||
});
|
||||
|
||||
const onSubmit = async (event: FormSubmitEvent<Schema>) => {
|
||||
submitting.value = true;
|
||||
try {
|
||||
if (props.link) {
|
||||
await $fetch(`/api/links/${props.link.id}`, { method: "PATCH", body: event.data, });
|
||||
toast.add({ title: "Link updated", color: "success" });
|
||||
} else {
|
||||
await $fetch("/api/links", { method: "POST", body: event.data });
|
||||
toast.add({ title: "Link created", color: "success" });
|
||||
}
|
||||
emit("close", true);
|
||||
} catch (error) {
|
||||
toast.add({ title: getApiError(error), color: "error" });
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UModal :title="link ? 'Edit link' : 'New link'">
|
||||
<template #body>
|
||||
<UForm
|
||||
:schema="schema"
|
||||
:state="state"
|
||||
class="space-y-4"
|
||||
@submit="onSubmit"
|
||||
>
|
||||
<UFormField label="Name" name="name">
|
||||
<UInput v-model="state.name" placeholder="my-link" class="w-full" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Path" name="path">
|
||||
<UInput v-model="state.path" placeholder="/go/somewhere" class="w-full" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="URL" name="url">
|
||||
<UInput v-model="state.url" placeholder="https://example.com" class="w-full" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField name="disabled">
|
||||
<UCheckbox v-model="state.disabled" label="Disabled" />
|
||||
</UFormField>
|
||||
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<UButton variant="ghost" @click="emit('close', false)">Cancel</UButton>
|
||||
<UButton type="submit" variant="subtle" :loading="submitting" :icon="link ? 'i-lucide-save' : 'i-lucide-plus'">{{ link ? "Save" : "Create" }}</UButton>
|
||||
</div>
|
||||
</UForm>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
75
app/components/LinksTable.vue
Normal file
75
app/components/LinksTable.vue
Normal file
@@ -0,0 +1,75 @@
|
||||
<script setup lang="ts">
|
||||
import type { TableColumn } from "@nuxt/ui";
|
||||
|
||||
const UBadge = resolveComponent("UBadge");
|
||||
|
||||
defineProps<{
|
||||
data: Link[];
|
||||
status: "pending" | "idle" | "success" | "error";
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
edit: [link: Link];
|
||||
delete: [link: Link];
|
||||
}>();
|
||||
|
||||
const UButton = resolveComponent("UButton");
|
||||
|
||||
const columns: TableColumn<Link>[] = [
|
||||
{ accessorKey: "name", header: "Name" },
|
||||
{ accessorKey: "path", header: "Path" },
|
||||
{
|
||||
accessorKey: "url",
|
||||
header: "URL",
|
||||
cell: ({ row }) =>
|
||||
h("a", {
|
||||
href: row.original.url,
|
||||
target: "_blank",
|
||||
class: "text-primary hover:underline truncate max-w-xs block",
|
||||
},
|
||||
row.original.url,
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "disabled",
|
||||
header: "Status",
|
||||
cell: ({ row }) =>
|
||||
h(UBadge, {
|
||||
label: row.original.disabled ? "Disabled" : "Active",
|
||||
color: row.original.disabled ? "error" : "success",
|
||||
variant: "subtle",
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) =>
|
||||
h("div", { class: "flex justify-end gap-1" }, [
|
||||
h(UButton, {
|
||||
icon: "i-lucide-pencil",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => emit("edit", row.original),
|
||||
}),
|
||||
h(UButton, {
|
||||
icon: "i-lucide-trash-2",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
color: "error",
|
||||
onClick: () => emit("delete", row.original),
|
||||
}),
|
||||
]),
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UTable
|
||||
:data="data"
|
||||
:columns="columns"
|
||||
:loading="status === 'pending' || status === 'idle'"
|
||||
>
|
||||
<template #empty>
|
||||
{{ status === "pending" || status === "idle" ? "Loading..." : "No data" }}
|
||||
</template>
|
||||
</UTable>
|
||||
</template>
|
||||
20
app/error.vue
Normal file
20
app/error.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import type { NuxtError } from "#app"
|
||||
|
||||
defineProps<{ error: NuxtError }>();
|
||||
useHead({ title: 'Not found' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UApp>
|
||||
<div class="min-h-screen flex items-center justify-center">
|
||||
<div class="text-center space-y-2">
|
||||
<p class="text-8xl font-black tracking-tight">404</p>
|
||||
<p class="text-lg font-medium">This link doesn't exist.</p>
|
||||
<p class="text-muted text-sm">
|
||||
Maybe check out <a href="https://pihkaal.me" class="underline underline-offset-2 hover:text-default transition-colors">pihkaal.me</a> instead?
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</UApp>
|
||||
</template>
|
||||
7
app/middleware/auth.ts
Normal file
7
app/middleware/auth.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export default defineNuxtRouteMiddleware(() => {
|
||||
const { loggedIn } = useUserSession();
|
||||
|
||||
if (!loggedIn.value) {
|
||||
return navigateTo("/auth/sign-in");
|
||||
}
|
||||
});
|
||||
61
app/pages/auth/sign-in.vue
Normal file
61
app/pages/auth/sign-in.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import { z } from "zod";
|
||||
import type { AuthFormField, FormSubmitEvent } from "@nuxt/ui";
|
||||
|
||||
useHead({ title: 'Sign in' });
|
||||
|
||||
definePageMeta({
|
||||
middleware: () => {
|
||||
const { loggedIn } = useUserSession();
|
||||
if (loggedIn.value) return navigateTo("/dashboard");
|
||||
},
|
||||
});
|
||||
|
||||
const fields: AuthFormField[] = [
|
||||
{ name: "username", type: "text", label: "Username", required: true },
|
||||
{ name: "password", type: "password", label: "Password", required: true },
|
||||
];
|
||||
|
||||
const schema = z.object({
|
||||
username: z.string({ error: "Required" }),
|
||||
password: z.string({ error: "Required" }),
|
||||
});
|
||||
|
||||
type Schema = z.output<typeof schema>;
|
||||
|
||||
const { fetch: fetchSession } = useUserSession();
|
||||
const error = ref<string | null>(null);
|
||||
const loading = ref(false);
|
||||
|
||||
const onSubmit = async (event: FormSubmitEvent<Schema>) => {
|
||||
error.value = null;
|
||||
loading.value = true;
|
||||
try {
|
||||
await $fetch("/api/auth/sign-in", { method: "POST", body: event.data });
|
||||
await fetchSession();
|
||||
await navigateTo("/dashboard");
|
||||
} catch (e) {
|
||||
error.value = getApiError(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-center min-h-screen">
|
||||
<UPageCard class="w-full max-w-sm">
|
||||
<UAuthForm
|
||||
title="Sign In"
|
||||
:fields="fields"
|
||||
:schema="schema"
|
||||
:loading="loading"
|
||||
@submit="onSubmit"
|
||||
>
|
||||
<template v-if="error" #validation>
|
||||
<UAlert color="error" icon="i-lucide-circle-alert" :title="error" />
|
||||
</template>
|
||||
</UAuthForm>
|
||||
</UPageCard>
|
||||
</div>
|
||||
</template>
|
||||
121
app/pages/dashboard.vue
Normal file
121
app/pages/dashboard.vue
Normal file
@@ -0,0 +1,121 @@
|
||||
<script setup lang="ts">
|
||||
import { LazyLinkModal, LazyConfirmModal } from "#components";
|
||||
|
||||
definePageMeta({ middleware: "auth" });
|
||||
|
||||
const toast = useToast();
|
||||
const overlay = useOverlay();
|
||||
|
||||
const { fetch: fetchSession } = useUserSession();
|
||||
|
||||
const signOut = async () => {
|
||||
await $fetch("/api/auth/sign-out", { method: "POST" });
|
||||
await fetchSession();
|
||||
await navigateTo("/auth/sign-in");
|
||||
};
|
||||
|
||||
const { data: links, status, refresh } = useLazyFetch("/api/links", { key: "links", server: false, });
|
||||
|
||||
const route = useRoute();
|
||||
const category = computed(() => {
|
||||
if (route.query.filter === "active") return "active";
|
||||
if (route.query.filter === "disabled") return "disabled";
|
||||
return "all";
|
||||
});
|
||||
|
||||
const categoryTitle = computed(() => {
|
||||
if (category.value === "active") return "Active links";
|
||||
if (category.value === "disabled") return "Disabled links";
|
||||
return "All links";
|
||||
});
|
||||
useHead({ title: categoryTitle });
|
||||
|
||||
const filteredLinks = computed(() => {
|
||||
if (!links.value) return [];
|
||||
if (category.value === "active") return links.value.filter((l) => !l.disabled);
|
||||
if (category.value === "disabled") return links.value.filter((l) => l.disabled);
|
||||
return links.value;
|
||||
});
|
||||
|
||||
const openModal = async (link: Link | null) => {
|
||||
const modal = overlay.create(LazyLinkModal, { destroyOnClose: true });
|
||||
const instance = modal.open({ link });
|
||||
if (await instance.result) await refresh();
|
||||
}
|
||||
|
||||
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 {
|
||||
await $fetch(`/api/links/${link.id}`, { method: "DELETE" });
|
||||
toast.add({ title: "Link deleted", color: "success" });
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
toast.add({ title: getApiError(error), color: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UDashboardGroup>
|
||||
<UDashboardSidebar :toggle="false" :ui="{ header: 'border-b border-default' }">
|
||||
<template #header>
|
||||
<span class="font-semibold text-highlighted text-lg">pihka.al</span>
|
||||
</template>
|
||||
|
||||
<UNavigationMenu
|
||||
orientation="vertical"
|
||||
highlight
|
||||
:items="[
|
||||
{
|
||||
label: 'All',
|
||||
icon: 'i-lucide-link',
|
||||
badge: links?.length ?? 0,
|
||||
to: '/dashboard',
|
||||
active: category === 'all',
|
||||
},
|
||||
{
|
||||
label: 'Active',
|
||||
icon: 'i-lucide-link-2',
|
||||
badge: links?.filter((l) => !l.disabled).length ?? 0,
|
||||
to: '/dashboard?filter=active',
|
||||
active: category === 'active',
|
||||
},
|
||||
{
|
||||
label: 'Disabled',
|
||||
icon: 'i-lucide-link-2-off',
|
||||
badge: links?.filter((l) => l.disabled).length ?? 0,
|
||||
to: '/dashboard?filter=disabled',
|
||||
active: category === 'disabled',
|
||||
},
|
||||
]"
|
||||
class="px-2"
|
||||
/>
|
||||
<template #footer>
|
||||
<UButton variant="link" icon="i-lucide-log-out" block @click="signOut">Sign out</UButton>
|
||||
</template>
|
||||
</UDashboardSidebar>
|
||||
|
||||
<UDashboardPanel>
|
||||
<template #header>
|
||||
<UDashboardNavbar :title="category === 'all' ? 'All links' : category === 'active' ? 'Active links' : 'Disabled links'">
|
||||
<template #right>
|
||||
<UButton icon="i-lucide-plus" variant="subtle" @click="openModal(null)">New link</UButton>
|
||||
</template>
|
||||
</UDashboardNavbar>
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<LinksTable
|
||||
:data="filteredLinks"
|
||||
:status="status"
|
||||
@edit="openModal"
|
||||
@delete="deleteLink"
|
||||
/>
|
||||
</template>
|
||||
</UDashboardPanel>
|
||||
</UDashboardGroup>
|
||||
</template>
|
||||
@@ -1,3 +0,0 @@
|
||||
<template>
|
||||
<div>ok</div>
|
||||
</template>
|
||||
6
app/plugins/redirect-error.server.ts
Normal file
6
app/plugins/redirect-error.server.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export default defineNuxtPlugin(() => {
|
||||
const event = useRequestEvent()
|
||||
if (event?.context.redirectNotFound) {
|
||||
showError({ statusCode: 404, message: 'Link not found' })
|
||||
}
|
||||
})
|
||||
25
app/utils/api.ts
Normal file
25
app/utils/api.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { z } from "zod";
|
||||
import type { InternalApi } from "nitropack/types";
|
||||
|
||||
export type Link = InternalApi["/api/links"]["get"][number];
|
||||
|
||||
const apiErrorSchema = z.object({
|
||||
data: z.object({
|
||||
statusCode: z.number(),
|
||||
message: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const getApiError = (
|
||||
error: unknown,
|
||||
defaultMessage: string = "Something went wrong",
|
||||
): string => {
|
||||
const parsedError = apiErrorSchema.safeParse(error);
|
||||
if (parsedError.success) {
|
||||
if (parsedError.data.data.statusCode === 500) {
|
||||
return `ERR-500: Internal server error`;
|
||||
}
|
||||
return `ERR-${parsedError.data.data.statusCode}: ${parsedError.data.data.message}`;
|
||||
}
|
||||
return defaultMessage;
|
||||
};
|
||||
22
docker-compose.dev.yml
Normal file
22
docker-compose.dev.yml
Normal file
@@ -0,0 +1,22 @@
|
||||
services:
|
||||
app:
|
||||
container_name: pihka-al-dev
|
||||
build:
|
||||
context: .
|
||||
target: build
|
||||
command: pnpm dev
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- DATABASE_URL=/data/db.sqlite
|
||||
- ADMIN_USERNAME=admin
|
||||
- ADMIN_PASSWORD=admin
|
||||
- REDIRECT_DOMAIN=localhost
|
||||
- NUXT_SESSION_PASSWORD=dev-session-password-32-chars-min
|
||||
volumes:
|
||||
- .:/app
|
||||
- /app/node_modules
|
||||
- db:/data
|
||||
|
||||
volumes:
|
||||
db:
|
||||
38
docker-compose.yml
Normal file
38
docker-compose.yml
Normal file
@@ -0,0 +1,38 @@
|
||||
services:
|
||||
app:
|
||||
container_name: pihka-al
|
||||
image: git.pihkaal.me/pihkaal/pihka-al:latest
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- DATABASE_URL=/data/db.sqlite
|
||||
- ADMIN_USERNAME
|
||||
- ADMIN_PASSWORD
|
||||
- REDIRECT_DOMAIN
|
||||
- NUXT_SESSION_PASSWORD
|
||||
volumes:
|
||||
- db:/data
|
||||
networks:
|
||||
- web
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.services.pihka-al.loadbalancer.server.port=3000
|
||||
|
||||
# dashboard domain
|
||||
- traefik.http.routers.pihka-al-dashboard.rule=Host(`${DASHBOARD_DOMAIN}`)
|
||||
- traefik.http.routers.pihka-al-dashboard.tls.certresolver=myresolver
|
||||
- traefik.http.routers.pihka-al.tls=true
|
||||
- traefik.http.routers.pihka-al-dashboard.service=pihka-al
|
||||
|
||||
# redirect domain
|
||||
- traefik.http.routers.pihka-al-redirect.rule=Host(`${REDIRECT_DOMAIN}`)
|
||||
- traefik.http.routers.pihka-al-redirect.tls.certresolver=myresolver
|
||||
- traefik.http.routers.pihka-al.tls=true
|
||||
- traefik.http.routers.pihka-al-redirect.service=pihka-al
|
||||
- traefik.http.routers.pihka-al.middlewares=umami-middleware@file
|
||||
|
||||
volumes:
|
||||
db:
|
||||
|
||||
networks:
|
||||
web:
|
||||
external: true
|
||||
BIN
docs/demo.gif
Normal file
BIN
docs/demo.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 577 KiB |
11
drizzle.config.ts
Normal file
11
drizzle.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'drizzle-kit'
|
||||
import { env } from './server/env'
|
||||
|
||||
export default defineConfig({
|
||||
schema: './server/db/schema.ts',
|
||||
out: './server/db/migrations',
|
||||
dialect: 'sqlite',
|
||||
dbCredentials: {
|
||||
url: env.DATABASE_URL,
|
||||
},
|
||||
})
|
||||
@@ -1,9 +1,21 @@
|
||||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||
export default defineNuxtConfig({
|
||||
modules: [
|
||||
'@nuxt/eslint',
|
||||
'@nuxt/ui'
|
||||
],
|
||||
modules: ['@nuxt/eslint', '@nuxt/ui', 'nuxt-auth-utils'],
|
||||
|
||||
vite: {
|
||||
optimizeDeps: {
|
||||
include: [
|
||||
'zod',
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
app: {
|
||||
head: {
|
||||
titleTemplate: '%s · pihka.al',
|
||||
link: [{ rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' }],
|
||||
},
|
||||
},
|
||||
|
||||
devtools: {
|
||||
enabled: true
|
||||
@@ -11,18 +23,11 @@ export default defineNuxtConfig({
|
||||
|
||||
css: ['~/assets/css/main.css'],
|
||||
|
||||
routeRules: {
|
||||
'/': { prerender: true }
|
||||
nitro: {
|
||||
externals: {
|
||||
external: ['better-sqlite3'],
|
||||
},
|
||||
},
|
||||
|
||||
compatibilityDate: '2025-01-15',
|
||||
|
||||
eslint: {
|
||||
config: {
|
||||
stylistic: {
|
||||
commaDangle: 'never',
|
||||
braceStyle: '1tbs'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
16
package.json
16
package.json
@@ -8,15 +8,27 @@
|
||||
"preview": "nuxt preview",
|
||||
"postinstall": "nuxt prepare",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "nuxt typecheck"
|
||||
"typecheck": "nuxt typecheck",
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:studio": "drizzle-kit studio"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nuxt/ui": "^4.5.1",
|
||||
"better-sqlite3": "^12.8.0",
|
||||
"dotenv": "^17.3.1",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"nuxt": "^4.4.2",
|
||||
"tailwindcss": "^4.2.1"
|
||||
"nuxt-auth-utils": "0.5.29",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify-json/lucide": "^1.2.98",
|
||||
"@nuxt/eslint": "^1.15.2",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"eslint": "^10.0.3",
|
||||
"typescript": "^5.9.3",
|
||||
"vue-tsc": "^3.2.5"
|
||||
|
||||
1194
pnpm-lock.yaml
generated
1194
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -4,3 +4,5 @@ ignoredBuiltDependencies:
|
||||
- esbuild
|
||||
- unrs-resolver
|
||||
- vue-demi
|
||||
onlyBuiltDependencies:
|
||||
- better-sqlite3
|
||||
|
||||
6
pnpm-workspace.yml
Normal file
6
pnpm-workspace.yml
Normal file
@@ -0,0 +1,6 @@
|
||||
onlyBuiltDependencies:
|
||||
- "@parcel/watcher"
|
||||
- better-sqlite3
|
||||
- esbuild
|
||||
- unrs-resolver
|
||||
- vue-demi
|
||||
51
public/favicon.svg
Normal file
51
public/favicon.svg
Normal file
@@ -0,0 +1,51 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 18">
|
||||
<style>
|
||||
.bg { fill: #000000; }
|
||||
.fg { fill: #ffffff; }
|
||||
</style>
|
||||
|
||||
<rect x="0" y="11" width="5" height="5" class="bg" />
|
||||
<rect x="1" y="10" width="7" height="5" class="bg" />
|
||||
<rect x="6" y="10" width="22" height="6" class="bg" />
|
||||
<rect x="9" y="5" width="18" height="12" class="bg" />
|
||||
<rect x="10" y="16" width="16" height="2" class="bg" />
|
||||
<rect x="7" y="7" width="16" height="4" class="bg" />
|
||||
<rect x="8" y="6" width="2" height="2" class="bg" />
|
||||
<rect x="26" y="9" width="2" height="2" class="bg" />
|
||||
<rect x="11" y="4" width="16" height="2" class="bg" />
|
||||
<rect x="12" y="3" width="15" height="2" class="bg" />
|
||||
<rect x="13" y="2" width="6" height="2" class="bg" />
|
||||
<rect x="14" y="1" width="5" height="2" class="bg" />
|
||||
<rect x="15" y="0" width="3" height="2" class="bg" />
|
||||
<rect x="21" y="2" width="6" height="2" class="bg" />
|
||||
<rect x="22" y="1" width="5" height="2" class="bg" />
|
||||
<rect x="23" y="0" width="3" height="2" class="bg" />
|
||||
|
||||
<rect x="2" y="11" width="5" height="3" class="fg" />
|
||||
<rect x="1" y="12" width="3" height="3" class="fg" />
|
||||
<rect x="10" y="6" width="16" height="10" class="fg" />
|
||||
<rect x="8" y="10" width="19" height="5" class="fg" />
|
||||
<rect x="7" y="12" width="2" height="3" class="fg" />
|
||||
<rect x="6" y="12" width="2" height="2" class="fg" />
|
||||
<rect x="8" y="8" width="18" height="3" class="fg" />
|
||||
<rect x="9" y="7" width="2" height="2" class="fg" />
|
||||
<rect x="12" y="15" width="13" height="2" class="fg" />
|
||||
<rect x="25" y="10" width="2" height="5" class="fg" />
|
||||
<rect x="15" y="11" width="3" height="1" class="bg" />
|
||||
<rect x="22" y="11" width="3" height="1" class="bg" />
|
||||
<rect x="12" y="5" width="14" height="2" class="fg" />
|
||||
<rect x="13" y="4" width="6" height="2" class="fg" />
|
||||
<rect x="14" y="3" width="4" height="2" class="fg" />
|
||||
<rect x="15" y="2" width="3" height="2" class="fg" />
|
||||
<rect x="16" y="1" width="1" height="2" class="fg" />
|
||||
<rect x="21" y="4" width="5" height="2" class="fg" />
|
||||
<rect x="22" y="3" width="4" height="2" class="fg" />
|
||||
<rect x="23" y="2" width="3" height="2" class="fg" />
|
||||
<rect x="24" y="1" width="1" height="2" class="fg" />
|
||||
<rect x="16" y="3" width="1" height="2" class="bg" />
|
||||
<rect x="15" y="4" width="1" height="2" class="bg" />
|
||||
<rect x="14" y="5" width="2" height="1" class="bg" />
|
||||
<rect x="15" y="4" width="2" height="1" class="bg" />
|
||||
<rect x="24" y="3" width="1" height="3" class="bg" />
|
||||
<rect x="23" y="4" width="2" height="1" class="bg" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
2
public/robots.txt
Normal file
2
public/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
52
server/api/auth/sign-in.post.ts
Normal file
52
server/api/auth/sign-in.post.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import { z } from "zod";
|
||||
import { env } from "#server/env";
|
||||
|
||||
const bodySchema = z.object({
|
||||
username: 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) => {
|
||||
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 valid = safeEqual(body.username, env.ADMIN_USERNAME) && safeEqual(body.password, env.ADMIN_PASSWORD);
|
||||
if (!valid) {
|
||||
throw createError({ statusCode: 401, message: "Invalid credentials" });
|
||||
}
|
||||
|
||||
await setUserSession(event, { user: { username: body.username } });
|
||||
});
|
||||
3
server/api/auth/sign-out.post.ts
Normal file
3
server/api/auth/sign-out.post.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
await clearUserSession(event);
|
||||
});
|
||||
22
server/api/links/[id]/index.delete.ts
Normal file
22
server/api/links/[id]/index.delete.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { db } from "#server/db";
|
||||
import * as tables from "#server/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
|
||||
const paramsSchema = z.object({
|
||||
id: z.string().transform(Number),
|
||||
});
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const params = await getValidatedRouterParams(event, paramsSchema.parse);
|
||||
|
||||
const [link] = await db
|
||||
.delete(tables.links)
|
||||
.where(eq(tables.links.id, params.id))
|
||||
.returning();
|
||||
if (!link) {
|
||||
throw createError({ statusCode: 404, message: "Link not found" });
|
||||
}
|
||||
|
||||
return link;
|
||||
});
|
||||
21
server/api/links/[id]/index.get.ts
Normal file
21
server/api/links/[id]/index.get.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { db } from "#server/db";
|
||||
import * as tables from "#server/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
|
||||
const paramsSchema = z.object({
|
||||
id: z.string().transform(Number),
|
||||
});
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const params = await getValidatedRouterParams(event, paramsSchema.parse);
|
||||
|
||||
const link = await db.query.links.findFirst({
|
||||
where: eq(tables.links.id, params.id),
|
||||
});
|
||||
if (!link) {
|
||||
throw createError({ statusCode: 404, message: "Link not found" });
|
||||
}
|
||||
|
||||
return link;
|
||||
});
|
||||
40
server/api/links/[id]/index.patch.ts
Normal file
40
server/api/links/[id]/index.patch.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { db } from "#server/db";
|
||||
import * as tables from "#server/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
|
||||
const paramsSchema = z.object({
|
||||
id: z.string().transform(Number),
|
||||
});
|
||||
|
||||
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);
|
||||
const body = await readValidatedBody(event, bodySchema.parse);
|
||||
|
||||
const conflicts = await findConflictingFields(body, params.id);
|
||||
if (conflicts.length > 0) {
|
||||
throw createError({ statusCode: 409, message: `A link with this ${joinFields(conflicts)} already exists` });
|
||||
}
|
||||
|
||||
const [link] = await db
|
||||
.update(tables.links)
|
||||
.set(body)
|
||||
.where(eq(tables.links.id, params.id))
|
||||
.returning();
|
||||
if (!link) {
|
||||
throw createError({ statusCode: 404, message: "Link not found" });
|
||||
}
|
||||
|
||||
return link;
|
||||
});
|
||||
5
server/api/links/index.get.ts
Normal file
5
server/api/links/index.get.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { db } from "#server/db";
|
||||
|
||||
export default defineEventHandler(async () => {
|
||||
return db.query.links.findMany();
|
||||
});
|
||||
22
server/api/links/index.post.ts
Normal file
22
server/api/links/index.post.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { db } from "#server/db";
|
||||
import * as tables from "#server/db/schema";
|
||||
import { z } from "zod";
|
||||
|
||||
const bodySchema = z.object({
|
||||
name: z.string().min(1),
|
||||
path: z.string().min(1).startsWith("/"),
|
||||
url: z.url(),
|
||||
disabled: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readValidatedBody(event, bodySchema.parse);
|
||||
|
||||
const conflicts = await findConflictingFields(body);
|
||||
if (conflicts.length > 0) {
|
||||
throw createError({ statusCode: 409, message: `A link with this ${joinFields(conflicts)} already exists` });
|
||||
}
|
||||
|
||||
const [link] = await db.insert(tables.links).values(body).returning();
|
||||
return link;
|
||||
});
|
||||
7
server/db/index.ts
Normal file
7
server/db/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import Database from 'better-sqlite3'
|
||||
import { drizzle } from 'drizzle-orm/better-sqlite3'
|
||||
import * as schema from './schema'
|
||||
import { env } from '../env'
|
||||
|
||||
const sqlite = new Database(env.DATABASE_URL)
|
||||
export const db = drizzle(sqlite, { schema })
|
||||
10
server/db/migrations/0000_pretty_mentor.sql
Normal file
10
server/db/migrations/0000_pretty_mentor.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
CREATE TABLE `links` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`path` text NOT NULL,
|
||||
`url` text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `links_name_unique` ON `links` (`name`);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `links_path_unique` ON `links` (`path`);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `links_url_unique` ON `links` (`url`);
|
||||
1
server/db/migrations/0001_tense_grey_gargoyle.sql
Normal file
1
server/db/migrations/0001_tense_grey_gargoyle.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE `links` ADD `disabled` integer DEFAULT false NOT NULL;
|
||||
1
server/db/migrations/0002_narrow_raider.sql
Normal file
1
server/db/migrations/0002_narrow_raider.sql
Normal file
@@ -0,0 +1 @@
|
||||
DROP INDEX `links_url_unique`;
|
||||
78
server/db/migrations/meta/0000_snapshot.json
Normal file
78
server/db/migrations/meta/0000_snapshot.json
Normal file
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "d03860ad-0d5c-4943-92ba-d704c74e1501",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"tables": {
|
||||
"links": {
|
||||
"name": "links",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"path": {
|
||||
"name": "path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"url": {
|
||||
"name": "url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"links_name_unique": {
|
||||
"name": "links_name_unique",
|
||||
"columns": [
|
||||
"name"
|
||||
],
|
||||
"isUnique": true
|
||||
},
|
||||
"links_path_unique": {
|
||||
"name": "links_path_unique",
|
||||
"columns": [
|
||||
"path"
|
||||
],
|
||||
"isUnique": true
|
||||
},
|
||||
"links_url_unique": {
|
||||
"name": "links_url_unique",
|
||||
"columns": [
|
||||
"url"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
86
server/db/migrations/meta/0001_snapshot.json
Normal file
86
server/db/migrations/meta/0001_snapshot.json
Normal file
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "0e8befef-3e4f-43f1-a031-95c2ca514e30",
|
||||
"prevId": "d03860ad-0d5c-4943-92ba-d704c74e1501",
|
||||
"tables": {
|
||||
"links": {
|
||||
"name": "links",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"path": {
|
||||
"name": "path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"url": {
|
||||
"name": "url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"disabled": {
|
||||
"name": "disabled",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"links_name_unique": {
|
||||
"name": "links_name_unique",
|
||||
"columns": [
|
||||
"name"
|
||||
],
|
||||
"isUnique": true
|
||||
},
|
||||
"links_path_unique": {
|
||||
"name": "links_path_unique",
|
||||
"columns": [
|
||||
"path"
|
||||
],
|
||||
"isUnique": true
|
||||
},
|
||||
"links_url_unique": {
|
||||
"name": "links_url_unique",
|
||||
"columns": [
|
||||
"url"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
79
server/db/migrations/meta/0002_snapshot.json
Normal file
79
server/db/migrations/meta/0002_snapshot.json
Normal file
@@ -0,0 +1,79 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "a78f922b-eacd-483e-b14d-5cdfadaac74b",
|
||||
"prevId": "0e8befef-3e4f-43f1-a031-95c2ca514e30",
|
||||
"tables": {
|
||||
"links": {
|
||||
"name": "links",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"path": {
|
||||
"name": "path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"url": {
|
||||
"name": "url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"disabled": {
|
||||
"name": "disabled",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"links_name_unique": {
|
||||
"name": "links_name_unique",
|
||||
"columns": [
|
||||
"name"
|
||||
],
|
||||
"isUnique": true
|
||||
},
|
||||
"links_path_unique": {
|
||||
"name": "links_path_unique",
|
||||
"columns": [
|
||||
"path"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
27
server/db/migrations/meta/_journal.json
Normal file
27
server/db/migrations/meta/_journal.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1773788954273,
|
||||
"tag": "0000_pretty_mentor",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1773792538248,
|
||||
"tag": "0001_tense_grey_gargoyle",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "6",
|
||||
"when": 1774463081828,
|
||||
"tag": "0002_narrow_raider",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
9
server/db/schema.ts
Normal file
9
server/db/schema.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
|
||||
|
||||
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(),
|
||||
disabled: integer("disabled", { mode: "boolean" }).notNull().default(false),
|
||||
});
|
||||
11
server/env.ts
Normal file
11
server/env.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import 'dotenv/config'
|
||||
import { z } from 'zod'
|
||||
|
||||
const schema = z.object({
|
||||
DATABASE_URL: z.string().min(1),
|
||||
ADMIN_USERNAME: z.string().min(1).default("admin"),
|
||||
ADMIN_PASSWORD: z.string().min(1),
|
||||
REDIRECT_DOMAIN: z.string().min(1),
|
||||
})
|
||||
|
||||
export const env = schema.parse(process.env)
|
||||
7
server/middleware/auth.ts
Normal file
7
server/middleware/auth.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
const path = getRequestURL(event).pathname;
|
||||
|
||||
if (path.startsWith("/api/") && !path.startsWith("/api/auth/")) {
|
||||
await requireUserSession(event);
|
||||
}
|
||||
});
|
||||
28
server/middleware/redirect.ts
Normal file
28
server/middleware/redirect.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { db } from "#server/db";
|
||||
import * as tables from "#server/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { env } from "#server/env";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const host = getRequestHost(event, { xForwardedHost: true }).split(":")[0];
|
||||
|
||||
if (host !== env.REDIRECT_DOMAIN) {
|
||||
if (getRequestURL(event).pathname === "/") {
|
||||
return sendRedirect(event, "/dashboard", 302);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const path = getRequestURL(event).pathname;
|
||||
|
||||
const link = await db.query.links.findFirst({
|
||||
where: eq(tables.links.path, path),
|
||||
});
|
||||
|
||||
if (!link || link.disabled) {
|
||||
event.context.redirectNotFound = true;
|
||||
return;
|
||||
}
|
||||
|
||||
return sendRedirect(event, link.url, 302);
|
||||
});
|
||||
6
server/plugins/migrations.ts
Normal file
6
server/plugins/migrations.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { migrate } from 'drizzle-orm/better-sqlite3/migrator'
|
||||
import { db } from '../db'
|
||||
|
||||
export default defineNitroPlugin(() => {
|
||||
migrate(db, { migrationsFolder: 'server/db/migrations' })
|
||||
})
|
||||
34
server/utils/links.ts
Normal file
34
server/utils/links.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { db } from "#server/db";
|
||||
import * as tables from "#server/db/schema";
|
||||
import { and, eq, ne } from "drizzle-orm";
|
||||
|
||||
const UNIQUE_FIELDS = ["name", "path"] as const;
|
||||
type UniqueField = (typeof UNIQUE_FIELDS)[number];
|
||||
|
||||
export const joinFields = (fields: string[]): string => {
|
||||
if (fields.length <= 1) return fields.join("");
|
||||
return `${fields.slice(0, -1).join(", ")} and ${fields.at(-1)}`;
|
||||
};
|
||||
|
||||
export const findConflictingFields = async (
|
||||
values: Partial<Record<UniqueField, string>>,
|
||||
excludeId?: number,
|
||||
): Promise<UniqueField[]> => {
|
||||
const conflicts: UniqueField[] = [];
|
||||
|
||||
for (const field of UNIQUE_FIELDS) {
|
||||
const value = values[field];
|
||||
if (!value) continue;
|
||||
|
||||
const conflict = await db.query.links.findFirst({
|
||||
where: and(
|
||||
eq(tables.links[field], value),
|
||||
excludeId !== undefined ? ne(tables.links.id, excludeId) : undefined,
|
||||
),
|
||||
});
|
||||
|
||||
if (conflict) conflicts.push(field);
|
||||
}
|
||||
|
||||
return conflicts;
|
||||
};
|
||||
Reference in New Issue
Block a user