feat: use portainer hook instead of pushing image to gitea package repository
This commit is contained in:
@@ -1,34 +0,0 @@
|
|||||||
name: ci
|
|
||||||
|
|
||||||
on: push
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
ci:
|
|
||||||
runs-on: ${{ matrix.os }}
|
|
||||||
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
os: [ubuntu-latest]
|
|
||||||
node: [22]
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v6
|
|
||||||
|
|
||||||
- name: Install pnpm
|
|
||||||
uses: pnpm/action-setup@v4
|
|
||||||
|
|
||||||
- name: Install node
|
|
||||||
uses: actions/setup-node@v6
|
|
||||||
with:
|
|
||||||
node-version: ${{ matrix.node }}
|
|
||||||
cache: pnpm
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pnpm install
|
|
||||||
|
|
||||||
- name: Lint
|
|
||||||
run: pnpm run lint
|
|
||||||
|
|
||||||
- name: Typecheck
|
|
||||||
run: pnpm run typecheck
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
name: Build and Push Docker Image
|
name: Deploy
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -11,24 +11,40 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
|
- name: Install node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: pnpm run lint
|
||||||
|
|
||||||
|
- name: Typecheck
|
||||||
|
run: pnpm run typecheck
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Log in to Gitea Container Registry
|
- name: Build Docker image
|
||||||
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
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: true
|
push: false
|
||||||
tags: git.pihkaal.me/pihkaal/pihka-al:latest
|
|
||||||
cache-from: type=registry,ref=git.pihkaal.me/pihkaal/pihka-al:cache
|
deploy:
|
||||||
cache-to: type=registry,ref=git.pihkaal.me/pihkaal/pihka-al:cache,mode=max
|
runs-on: ubuntu-latest
|
||||||
|
needs: build
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Trigger Portainer webhook
|
||||||
|
run: curl -X POST "${{ secrets.PORTAINER_WEBHOOK_URL }}"
|
||||||
|
|||||||
69
app/components/FilesTable.vue
Normal file
69
app/components/FilesTable.vue
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { TableColumn } from "@nuxt/ui";
|
||||||
|
|
||||||
|
const UButton = resolveComponent("UButton");
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
data: FileEntry[];
|
||||||
|
status: "pending" | "idle" | "success" | "error";
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
rename: [file: FileEntry];
|
||||||
|
delete: [file: FileEntry];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const formatSize = (bytes: number): string => {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (iso: string): string =>
|
||||||
|
new Date(iso).toLocaleDateString(undefined, { dateStyle: "medium" });
|
||||||
|
|
||||||
|
const columns: TableColumn<FileEntry>[] = [
|
||||||
|
{ accessorKey: "name", header: "Name" },
|
||||||
|
{
|
||||||
|
accessorKey: "size",
|
||||||
|
header: "Size",
|
||||||
|
cell: ({ row }) => formatSize(row.original.size),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "modifiedAt",
|
||||||
|
header: "Modified",
|
||||||
|
cell: ({ row }) => formatDate(row.original.modifiedAt),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
cell: ({ row }) =>
|
||||||
|
h("div", { class: "flex justify-end gap-1" }, [
|
||||||
|
h(UButton, {
|
||||||
|
icon: "i-lucide-pencil",
|
||||||
|
variant: "ghost",
|
||||||
|
size: "sm",
|
||||||
|
onClick: () => emit("rename", 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 files" }}
|
||||||
|
</template>
|
||||||
|
</UTable>
|
||||||
|
</template>
|
||||||
59
app/components/RenameFileModal.vue
Normal file
59
app/components/RenameFileModal.vue
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { z } from "zod";
|
||||||
|
import type { FormSubmitEvent } from "@nuxt/ui";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
filename: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
name: z
|
||||||
|
.string({ error: "Required" })
|
||||||
|
.min(1)
|
||||||
|
.regex(/^[^/\\]+$/, "Invalid filename"),
|
||||||
|
});
|
||||||
|
|
||||||
|
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.filename,
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (event: FormSubmitEvent<Schema>) => {
|
||||||
|
submitting.value = true;
|
||||||
|
try {
|
||||||
|
await $fetch(`/api/files/${encodeURIComponent(props.filename)}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: event.data,
|
||||||
|
});
|
||||||
|
toast.add({ title: "File renamed", color: "success" });
|
||||||
|
emit("close", true);
|
||||||
|
} catch (error) {
|
||||||
|
toast.add({ title: getApiError(error), color: "error" });
|
||||||
|
} finally {
|
||||||
|
submitting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UModal title="Rename file">
|
||||||
|
<template #body>
|
||||||
|
<UForm :schema="schema" :state="state" class="space-y-4" @submit="onSubmit">
|
||||||
|
<UFormField label="Name" name="name">
|
||||||
|
<UInput v-model="state.name" class="w-full" />
|
||||||
|
</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="i-lucide-save">Save</UButton>
|
||||||
|
</div>
|
||||||
|
</UForm>
|
||||||
|
</template>
|
||||||
|
</UModal>
|
||||||
|
</template>
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { LazyLinkModal, LazyConfirmModal } from "#components";
|
import { LazyLinkModal, LazyConfirmModal, LazyRenameFileModal } from "#components";
|
||||||
|
|
||||||
definePageMeta({ middleware: "auth" });
|
definePageMeta({ middleware: "auth" });
|
||||||
|
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const overlay = useOverlay();
|
const overlay = useOverlay();
|
||||||
|
const fileInput = ref<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
const { fetch: fetchSession } = useUserSession();
|
const { fetch: fetchSession } = useUserSession();
|
||||||
|
|
||||||
@@ -14,10 +15,13 @@ const signOut = async () => {
|
|||||||
await navigateTo("/auth/sign-in");
|
await navigateTo("/auth/sign-in");
|
||||||
};
|
};
|
||||||
|
|
||||||
const { data: links, status, refresh } = useLazyFetch("/api/links", { key: "links", server: false, });
|
const { data: links, status: linksStatus, refresh: refreshLinks } = useLazyFetch("/api/links", { key: "links", server: false });
|
||||||
|
const { data: files, status: filesStatus, refresh: refreshFiles } = useLazyFetch("/api/files", { key: "files", server: false });
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
const section = computed(() => route.query.section === "files" ? "files" : "links");
|
||||||
const category = computed(() => {
|
const category = computed(() => {
|
||||||
|
if (section.value !== "links") return "all";
|
||||||
if (route.query.filter === "active") return "active";
|
if (route.query.filter === "active") return "active";
|
||||||
if (route.query.filter === "disabled") return "disabled";
|
if (route.query.filter === "disabled") return "disabled";
|
||||||
return "all";
|
return "all";
|
||||||
@@ -28,7 +32,9 @@ const categoryTitle = computed(() => {
|
|||||||
if (category.value === "disabled") return "Disabled links";
|
if (category.value === "disabled") return "Disabled links";
|
||||||
return "All links";
|
return "All links";
|
||||||
});
|
});
|
||||||
useHead({ title: categoryTitle });
|
|
||||||
|
const pageTitle = computed(() => section.value === "files" ? "Files" : categoryTitle.value);
|
||||||
|
useHead({ title: pageTitle });
|
||||||
|
|
||||||
const filteredLinks = computed(() => {
|
const filteredLinks = computed(() => {
|
||||||
if (!links.value) return [];
|
if (!links.value) return [];
|
||||||
@@ -37,11 +43,12 @@ const filteredLinks = computed(() => {
|
|||||||
return links.value;
|
return links.value;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Link actions
|
||||||
const openModal = async (link: Link | null) => {
|
const openModal = async (link: Link | null) => {
|
||||||
const modal = overlay.create(LazyLinkModal, { destroyOnClose: true });
|
const modal = overlay.create(LazyLinkModal, { destroyOnClose: true });
|
||||||
const instance = modal.open({ link });
|
const instance = modal.open({ link });
|
||||||
if (await instance.result) await refresh();
|
if (await instance.result) await refreshLinks();
|
||||||
}
|
};
|
||||||
|
|
||||||
const deleteLink = async (link: Link) => {
|
const deleteLink = async (link: Link) => {
|
||||||
const modal = overlay.create(LazyConfirmModal, { destroyOnClose: true });
|
const modal = overlay.create(LazyConfirmModal, { destroyOnClose: true });
|
||||||
@@ -51,12 +58,55 @@ const deleteLink = async (link: Link) => {
|
|||||||
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 refreshLinks();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.add({ title: getApiError(error), color: "error" });
|
toast.add({ title: getApiError(error), color: "error" });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// File actions
|
||||||
|
const uploading = ref(false);
|
||||||
|
|
||||||
|
const handleUpload = async (event: Event) => {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
if (!input.files || input.files.length === 0) return;
|
||||||
|
|
||||||
|
uploading.value = true;
|
||||||
|
try {
|
||||||
|
for (const file of Array.from(input.files)) {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("file", file);
|
||||||
|
await $fetch("/api/files", { method: "POST", body: form });
|
||||||
|
}
|
||||||
|
toast.add({ title: `${input.files.length === 1 ? "File" : "Files"} uploaded`, color: "success" });
|
||||||
|
await refreshFiles();
|
||||||
|
} catch (error) {
|
||||||
|
toast.add({ title: getApiError(error), color: "error" });
|
||||||
|
} finally {
|
||||||
|
uploading.value = false;
|
||||||
|
input.value = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renameFile = async (file: FileEntry) => {
|
||||||
|
const modal = overlay.create(LazyRenameFileModal, { destroyOnClose: true });
|
||||||
|
const instance = modal.open({ filename: file.name });
|
||||||
|
if (await instance.result) await refreshFiles();
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteFile = async (file: FileEntry) => {
|
||||||
|
const modal = overlay.create(LazyConfirmModal, { destroyOnClose: true });
|
||||||
|
const instance = modal.open({ title: "Delete file", description: `Are you sure you want to delete "${file.name}"?` });
|
||||||
|
if (!await instance.result) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await $fetch(`/api/files/${encodeURIComponent(file.name)}`, { method: "DELETE" });
|
||||||
|
toast.add({ title: "File deleted", color: "success" });
|
||||||
|
await refreshFiles();
|
||||||
|
} catch (error) {
|
||||||
|
toast.add({ title: getApiError(error), color: "error" });
|
||||||
|
}
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -66,42 +116,60 @@ const deleteLink = async (link: Link) => {
|
|||||||
<span class="font-semibold text-highlighted text-lg">pihka.al</span>
|
<span class="font-semibold text-highlighted text-lg">pihka.al</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<UNavigationMenu
|
<div class="px-2 space-y-2">
|
||||||
orientation="vertical"
|
<UNavigationMenu
|
||||||
highlight
|
orientation="vertical"
|
||||||
:items="[
|
highlight
|
||||||
{
|
:items="[
|
||||||
label: 'All',
|
{
|
||||||
icon: 'i-lucide-link',
|
label: 'All',
|
||||||
badge: links?.length ?? 0,
|
icon: 'i-lucide-link',
|
||||||
to: '/dashboard',
|
badge: links?.length ?? 0,
|
||||||
active: category === 'all',
|
to: '/dashboard',
|
||||||
},
|
active: section === 'links' && category === 'all',
|
||||||
{
|
},
|
||||||
label: 'Active',
|
{
|
||||||
icon: 'i-lucide-link-2',
|
label: 'Active',
|
||||||
badge: links?.filter((l) => !l.disabled).length ?? 0,
|
icon: 'i-lucide-link-2',
|
||||||
to: '/dashboard?filter=active',
|
badge: links?.filter((l) => !l.disabled).length ?? 0,
|
||||||
active: category === 'active',
|
to: '/dashboard?filter=active',
|
||||||
},
|
active: section === 'links' && category === 'active',
|
||||||
{
|
},
|
||||||
label: 'Disabled',
|
{
|
||||||
icon: 'i-lucide-link-2-off',
|
label: 'Disabled',
|
||||||
badge: links?.filter((l) => l.disabled).length ?? 0,
|
icon: 'i-lucide-link-2-off',
|
||||||
to: '/dashboard?filter=disabled',
|
badge: links?.filter((l) => l.disabled).length ?? 0,
|
||||||
active: category === 'disabled',
|
to: '/dashboard?filter=disabled',
|
||||||
},
|
active: section === 'links' && category === 'disabled',
|
||||||
]"
|
},
|
||||||
class="px-2"
|
]"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<USeparator />
|
||||||
|
|
||||||
|
<UNavigationMenu
|
||||||
|
orientation="vertical"
|
||||||
|
highlight
|
||||||
|
:items="[
|
||||||
|
{
|
||||||
|
label: 'Files',
|
||||||
|
icon: 'i-lucide-folder',
|
||||||
|
badge: files?.length ?? 0,
|
||||||
|
to: '/dashboard?section=files',
|
||||||
|
active: section === 'files',
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<UButton variant="link" icon="i-lucide-log-out" block @click="signOut">Sign out</UButton>
|
<UButton variant="link" icon="i-lucide-log-out" block @click="signOut">Sign out</UButton>
|
||||||
</template>
|
</template>
|
||||||
</UDashboardSidebar>
|
</UDashboardSidebar>
|
||||||
|
|
||||||
<UDashboardPanel>
|
<UDashboardPanel v-if="section === 'links'">
|
||||||
<template #header>
|
<template #header>
|
||||||
<UDashboardNavbar :title="category === 'all' ? 'All links' : category === 'active' ? 'Active links' : 'Disabled links'">
|
<UDashboardNavbar :title="categoryTitle">
|
||||||
<template #right>
|
<template #right>
|
||||||
<UButton icon="i-lucide-plus" variant="subtle" @click="openModal(null)">New link</UButton>
|
<UButton icon="i-lucide-plus" variant="subtle" @click="openModal(null)">New link</UButton>
|
||||||
</template>
|
</template>
|
||||||
@@ -111,11 +179,31 @@ const deleteLink = async (link: Link) => {
|
|||||||
<template #body>
|
<template #body>
|
||||||
<LinksTable
|
<LinksTable
|
||||||
:data="filteredLinks"
|
:data="filteredLinks"
|
||||||
:status="status"
|
:status="linksStatus"
|
||||||
@edit="openModal"
|
@edit="openModal"
|
||||||
@delete="deleteLink"
|
@delete="deleteLink"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</UDashboardPanel>
|
</UDashboardPanel>
|
||||||
|
|
||||||
|
<UDashboardPanel v-else>
|
||||||
|
<template #header>
|
||||||
|
<UDashboardNavbar title="Files">
|
||||||
|
<template #right>
|
||||||
|
<input ref="fileInput" type="file" class="hidden" multiple @change="handleUpload" />
|
||||||
|
<UButton icon="i-lucide-upload" variant="subtle" :loading="uploading" @click="(fileInput as HTMLInputElement)?.click()">Upload</UButton>
|
||||||
|
</template>
|
||||||
|
</UDashboardNavbar>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #body>
|
||||||
|
<FilesTable
|
||||||
|
:data="files ?? []"
|
||||||
|
:status="filesStatus"
|
||||||
|
@rename="renameFile"
|
||||||
|
@delete="deleteFile"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</UDashboardPanel>
|
||||||
</UDashboardGroup>
|
</UDashboardGroup>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { z } from "zod";
|
|||||||
import type { InternalApi } from "nitropack/types";
|
import type { InternalApi } from "nitropack/types";
|
||||||
|
|
||||||
export type Link = InternalApi["/api/links"]["get"][number];
|
export type Link = InternalApi["/api/links"]["get"][number];
|
||||||
|
export type FileEntry = { name: string; size: number; modifiedAt: string };
|
||||||
|
|
||||||
const apiErrorSchema = z.object({
|
const apiErrorSchema = z.object({
|
||||||
data: z.object({
|
data: z.object({
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
container_name: pihka-al
|
container_name: pihka-al
|
||||||
image: git.pihkaal.me/pihkaal/pihka-al:latest
|
build: .
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=/data/db.sqlite
|
- DATABASE_URL=/data/db.sqlite
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||||
export default defineNuxtConfig({
|
export default defineNuxtConfig({
|
||||||
modules: ['@nuxt/eslint', '@nuxt/ui', 'nuxt-auth-utils'],
|
modules: ['@nuxt/eslint', '@nuxt/ui', 'nuxt-auth-utils', 'nuxt-file-storage'],
|
||||||
|
|
||||||
vite: {
|
vite: {
|
||||||
optimizeDeps: {
|
optimizeDeps: {
|
||||||
@@ -30,4 +30,4 @@ export default defineNuxtConfig({
|
|||||||
},
|
},
|
||||||
|
|
||||||
compatibilityDate: '2025-01-15',
|
compatibilityDate: '2025-01-15',
|
||||||
})
|
})
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.1",
|
||||||
"nuxt": "^4.4.2",
|
"nuxt": "^4.4.2",
|
||||||
"nuxt-auth-utils": "0.5.29",
|
"nuxt-auth-utils": "0.5.29",
|
||||||
|
"nuxt-file-storage": "0.3.2",
|
||||||
"tailwindcss": "^4.2.1",
|
"tailwindcss": "^4.2.1",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
|
|||||||
13
pnpm-lock.yaml
generated
13
pnpm-lock.yaml
generated
@@ -26,6 +26,9 @@ importers:
|
|||||||
nuxt-auth-utils:
|
nuxt-auth-utils:
|
||||||
specifier: 0.5.29
|
specifier: 0.5.29
|
||||||
version: 0.5.29(magicast@0.5.2)
|
version: 0.5.29(magicast@0.5.2)
|
||||||
|
nuxt-file-storage:
|
||||||
|
specifier: 0.3.2
|
||||||
|
version: 0.3.2(magicast@0.5.2)
|
||||||
tailwindcss:
|
tailwindcss:
|
||||||
specifier: ^4.2.1
|
specifier: ^4.2.1
|
||||||
version: 4.2.1
|
version: 4.2.1
|
||||||
@@ -4252,6 +4255,9 @@ packages:
|
|||||||
'@simplewebauthn/server':
|
'@simplewebauthn/server':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
nuxt-file-storage@0.3.2:
|
||||||
|
resolution: {integrity: sha512-ET2bg2dSiSqiuos017h/6e+zE7BNvdugsppOBCJMfwz6Jvqg+So0QUELmNGloJC2y+CEt2fe09tvtHeqfFwP/w==}
|
||||||
|
|
||||||
nuxt@4.4.2:
|
nuxt@4.4.2:
|
||||||
resolution: {integrity: sha512-iWVFpr/YEqVU/CenqIHMnIkvb2HE/9f+q8oxZ+pj2et+60NljGRClCgnmbvGPdmNFE0F1bEhoBCYfqbDOCim3Q==}
|
resolution: {integrity: sha512-iWVFpr/YEqVU/CenqIHMnIkvb2HE/9f+q8oxZ+pj2et+60NljGRClCgnmbvGPdmNFE0F1bEhoBCYfqbDOCim3Q==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
@@ -9778,6 +9784,13 @@ snapshots:
|
|||||||
- bcrypt
|
- bcrypt
|
||||||
- magicast
|
- magicast
|
||||||
|
|
||||||
|
nuxt-file-storage@0.3.2(magicast@0.5.2):
|
||||||
|
dependencies:
|
||||||
|
'@nuxt/kit': 4.4.2(magicast@0.5.2)
|
||||||
|
defu: 6.1.4
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- magicast
|
||||||
|
|
||||||
nuxt@4.4.2(@babel/core@7.29.0)(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@parcel/watcher@2.5.6)(@types/node@25.5.0)(@vue/compiler-sfc@3.5.30)(better-sqlite3@12.8.0)(cac@6.7.14)(db0@0.3.4(better-sqlite3@12.8.0)(drizzle-orm@0.45.1(@types/better-sqlite3@7.6.13)(better-sqlite3@12.8.0)))(drizzle-orm@0.45.1(@types/better-sqlite3@7.6.13)(better-sqlite3@12.8.0))(eslint@10.0.3(jiti@2.6.1))(ioredis@5.10.0)(lightningcss@1.32.0)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@6.0.11(rollup@4.59.0))(rollup@4.59.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue-tsc@3.2.5(typescript@5.9.3))(yaml@2.8.2):
|
nuxt@4.4.2(@babel/core@7.29.0)(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@parcel/watcher@2.5.6)(@types/node@25.5.0)(@vue/compiler-sfc@3.5.30)(better-sqlite3@12.8.0)(cac@6.7.14)(db0@0.3.4(better-sqlite3@12.8.0)(drizzle-orm@0.45.1(@types/better-sqlite3@7.6.13)(better-sqlite3@12.8.0)))(drizzle-orm@0.45.1(@types/better-sqlite3@7.6.13)(better-sqlite3@12.8.0))(eslint@10.0.3(jiti@2.6.1))(ioredis@5.10.0)(lightningcss@1.32.0)(magicast@0.5.2)(optionator@0.9.4)(rollup-plugin-visualizer@6.0.11(rollup@4.59.0))(rollup@4.59.0)(terser@5.46.0)(tsx@4.21.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vue-tsc@3.2.5(typescript@5.9.3))(yaml@2.8.2):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@dxup/nuxt': 0.4.0(magicast@0.5.2)(typescript@5.9.3)
|
'@dxup/nuxt': 0.4.0(magicast@0.5.2)(typescript@5.9.3)
|
||||||
|
|||||||
13
public/files/pubkey.asc
Normal file
13
public/files/pubkey.asc
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||||
|
|
||||||
|
mDMEaUnZbxYJKwYBBAHaRw8BAQdABdo9ty0Y6CM0YuDnWBwSYhtoa11yEYcgSWRj
|
||||||
|
CJjvk8S0GlBpaGthYWwgPGhlbGxvQHBpaGthYWwubWU+iJYEExYKAD4WIQR3xxLs
|
||||||
|
eQDwQbaRfTdcynYY9m/fvQUCaUnZbwIbAwUJAeEzgAULCQgHAgYVCgkICwIEFgID
|
||||||
|
AQIeAQIXgAAKCRBcynYY9m/fva/9AQCsdzzCeJHfrX8Y3vLlNOf1urpJ22J2acIa
|
||||||
|
7mYc04NX8QEAtpMvXSqOWnfLszSXmU/jQSW2i6e2bb4ifmXjxVRpJQG4OARpSdlv
|
||||||
|
EgorBgEEAZdVAQUBAQdA7+ROVEdDM6OewnIwjbAvqErWqn0wXj9/JqfoV8dD5XsD
|
||||||
|
AQgHiH4EGBYKACYWIQR3xxLseQDwQbaRfTdcynYY9m/fvQUCaUnZbwIbDAUJAeEz
|
||||||
|
gAAKCRBcynYY9m/fvVQcAQCXK5a0t5nFzKn6FOa2W3232XNyOHkohvCKJHiojbKp
|
||||||
|
RgD/bn5ChqQXkskUJ//VdVNbSFpV22Z3jobNcTkPSwrIFwk=
|
||||||
|
=8Y+6
|
||||||
|
-----END PGP PUBLIC KEY BLOCK-----
|
||||||
24
server/api/files/[name]/index.delete.ts
Normal file
24
server/api/files/[name]/index.delete.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { unlink } from "node:fs/promises";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const paramsSchema = z.object({
|
||||||
|
name: z.string().min(1).regex(/^[^/\\]+$/, "Invalid filename"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const params = await getValidatedRouterParams(event, paramsSchema.parse);
|
||||||
|
const dir = resolve(process.cwd(), "public/files");
|
||||||
|
const filePath = resolve(dir, params.name);
|
||||||
|
|
||||||
|
if (!filePath.startsWith(dir + "/")) {
|
||||||
|
throw createError({ statusCode: 400, message: "Invalid filename" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await unlink(filePath);
|
||||||
|
return { success: true };
|
||||||
|
} catch {
|
||||||
|
throw createError({ statusCode: 404, message: "File not found" });
|
||||||
|
}
|
||||||
|
});
|
||||||
31
server/api/files/[name]/index.patch.ts
Normal file
31
server/api/files/[name]/index.patch.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { rename } from "node:fs/promises";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const paramsSchema = z.object({
|
||||||
|
name: z.string().min(1).regex(/^[^/\\]+$/, "Invalid filename"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const bodySchema = z.object({
|
||||||
|
name: z.string().min(1).regex(/^[^/\\]+$/, "Invalid filename"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const params = await getValidatedRouterParams(event, paramsSchema.parse);
|
||||||
|
const body = await readValidatedBody(event, bodySchema.parse);
|
||||||
|
|
||||||
|
const dir = resolve(process.cwd(), "public/files");
|
||||||
|
const oldPath = resolve(dir, params.name);
|
||||||
|
const newPath = resolve(dir, body.name);
|
||||||
|
|
||||||
|
if (!oldPath.startsWith(dir + "/") || !newPath.startsWith(dir + "/")) {
|
||||||
|
throw createError({ statusCode: 400, message: "Invalid filename" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await rename(oldPath, newPath);
|
||||||
|
return { name: body.name };
|
||||||
|
} catch {
|
||||||
|
throw createError({ statusCode: 404, message: "File not found" });
|
||||||
|
}
|
||||||
|
});
|
||||||
20
server/api/files/index.get.ts
Normal file
20
server/api/files/index.get.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { readdir, stat } from "node:fs/promises";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
const filesDir = () => resolve(process.cwd(), "public/files");
|
||||||
|
|
||||||
|
export default defineEventHandler(async () => {
|
||||||
|
const dir = filesDir();
|
||||||
|
try {
|
||||||
|
const names = await readdir(dir);
|
||||||
|
const files = await Promise.all(
|
||||||
|
names.map(async (name) => {
|
||||||
|
const s = await stat(resolve(dir, name));
|
||||||
|
return { name, size: s.size, modifiedAt: s.mtime.toISOString() };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return files;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
});
|
||||||
22
server/api/files/index.post.ts
Normal file
22
server/api/files/index.post.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { writeFile, mkdir } from "node:fs/promises";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const parts = await readMultipartFormData(event);
|
||||||
|
if (!parts || parts.length === 0) {
|
||||||
|
throw createError({ statusCode: 400, message: "No file provided" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePart = parts.find((p) => p.name === "file");
|
||||||
|
if (!filePart || !filePart.filename) {
|
||||||
|
throw createError({ statusCode: 400, message: "No file provided" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const filename = filePart.filename.replace(/[^a-zA-Z0-9._-]/g, "_");
|
||||||
|
const dir = resolve(process.cwd(), "public/files");
|
||||||
|
|
||||||
|
await mkdir(dir, { recursive: true });
|
||||||
|
await writeFile(resolve(dir, filename), filePart.data);
|
||||||
|
|
||||||
|
return { name: filename, size: filePart.data.length };
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user