diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml
deleted file mode 100644
index 58d9ce9..0000000
--- a/.gitea/workflows/ci.yml
+++ /dev/null
@@ -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
diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml
index b7783a2..d9e57fa 100644
--- a/.gitea/workflows/deploy.yml
+++ b/.gitea/workflows/deploy.yml
@@ -1,4 +1,4 @@
-name: Build and Push Docker Image
+name: Deploy
on:
push:
@@ -11,24 +11,40 @@ jobs:
runs-on: ubuntu-latest
steps:
- - name: Checkout code
+ - name: Checkout
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
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
+ - name: Build 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
+ push: false
+
+ deploy:
+ runs-on: ubuntu-latest
+ needs: build
+
+ steps:
+ - name: Trigger Portainer webhook
+ run: curl -X POST "${{ secrets.PORTAINER_WEBHOOK_URL }}"
diff --git a/app/components/FilesTable.vue b/app/components/FilesTable.vue
new file mode 100644
index 0000000..e6606a9
--- /dev/null
+++ b/app/components/FilesTable.vue
@@ -0,0 +1,69 @@
+
+
+
+
+
+ {{ status === "pending" || status === "idle" ? "Loading..." : "No files" }}
+
+
+
diff --git a/app/components/RenameFileModal.vue b/app/components/RenameFileModal.vue
new file mode 100644
index 0000000..dbfadd1
--- /dev/null
+++ b/app/components/RenameFileModal.vue
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+
+
+
+
+
+ Cancel
+ Save
+
+
+
+
+
diff --git a/app/pages/dashboard.vue b/app/pages/dashboard.vue
index 0411595..53eaf33 100644
--- a/app/pages/dashboard.vue
+++ b/app/pages/dashboard.vue
@@ -1,10 +1,11 @@
@@ -66,42 +116,60 @@ const deleteLink = async (link: Link) => {
pihka.al
-
+
+
+
+
+
+
+
+
Sign out
-
+
-
+
New link
@@ -111,11 +179,31 @@ const deleteLink = async (link: Link) => {
+
+
+
+
+
+
+ Upload
+
+
+
+
+
+
+
+
diff --git a/app/utils/api.ts b/app/utils/api.ts
index f259774..6ed3b67 100644
--- a/app/utils/api.ts
+++ b/app/utils/api.ts
@@ -2,6 +2,7 @@ import { z } from "zod";
import type { InternalApi } from "nitropack/types";
export type Link = InternalApi["/api/links"]["get"][number];
+export type FileEntry = { name: string; size: number; modifiedAt: string };
const apiErrorSchema = z.object({
data: z.object({
diff --git a/docker-compose.yml b/docker-compose.yml
index f528a69..675c7d3 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,7 +1,7 @@
services:
app:
container_name: pihka-al
- image: git.pihkaal.me/pihkaal/pihka-al:latest
+ build: .
restart: unless-stopped
environment:
- DATABASE_URL=/data/db.sqlite
diff --git a/nuxt.config.ts b/nuxt.config.ts
index b92fb19..898042c 100644
--- a/nuxt.config.ts
+++ b/nuxt.config.ts
@@ -1,6 +1,6 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
- modules: ['@nuxt/eslint', '@nuxt/ui', 'nuxt-auth-utils'],
+ modules: ['@nuxt/eslint', '@nuxt/ui', 'nuxt-auth-utils', 'nuxt-file-storage'],
vite: {
optimizeDeps: {
@@ -30,4 +30,4 @@ export default defineNuxtConfig({
},
compatibilityDate: '2025-01-15',
-})
+})
\ No newline at end of file
diff --git a/package.json b/package.json
index dad876c..97cf1b6 100644
--- a/package.json
+++ b/package.json
@@ -21,6 +21,7 @@
"drizzle-orm": "^0.45.1",
"nuxt": "^4.4.2",
"nuxt-auth-utils": "0.5.29",
+ "nuxt-file-storage": "0.3.2",
"tailwindcss": "^4.2.1",
"zod": "^4.3.6"
},
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 4c14d5b..7ccf026 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -26,6 +26,9 @@ importers:
nuxt-auth-utils:
specifier: 0.5.29
version: 0.5.29(magicast@0.5.2)
+ nuxt-file-storage:
+ specifier: 0.3.2
+ version: 0.3.2(magicast@0.5.2)
tailwindcss:
specifier: ^4.2.1
version: 4.2.1
@@ -4252,6 +4255,9 @@ packages:
'@simplewebauthn/server':
optional: true
+ nuxt-file-storage@0.3.2:
+ resolution: {integrity: sha512-ET2bg2dSiSqiuos017h/6e+zE7BNvdugsppOBCJMfwz6Jvqg+So0QUELmNGloJC2y+CEt2fe09tvtHeqfFwP/w==}
+
nuxt@4.4.2:
resolution: {integrity: sha512-iWVFpr/YEqVU/CenqIHMnIkvb2HE/9f+q8oxZ+pj2et+60NljGRClCgnmbvGPdmNFE0F1bEhoBCYfqbDOCim3Q==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -9778,6 +9784,13 @@ snapshots:
- bcrypt
- 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):
dependencies:
'@dxup/nuxt': 0.4.0(magicast@0.5.2)(typescript@5.9.3)
diff --git a/public/files/pubkey.asc b/public/files/pubkey.asc
new file mode 100644
index 0000000..fb84ea0
--- /dev/null
+++ b/public/files/pubkey.asc
@@ -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-----
diff --git a/server/api/files/[name]/index.delete.ts b/server/api/files/[name]/index.delete.ts
new file mode 100644
index 0000000..e7cb725
--- /dev/null
+++ b/server/api/files/[name]/index.delete.ts
@@ -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" });
+ }
+});
diff --git a/server/api/files/[name]/index.patch.ts b/server/api/files/[name]/index.patch.ts
new file mode 100644
index 0000000..26f996a
--- /dev/null
+++ b/server/api/files/[name]/index.patch.ts
@@ -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" });
+ }
+});
diff --git a/server/api/files/index.get.ts b/server/api/files/index.get.ts
new file mode 100644
index 0000000..84098ca
--- /dev/null
+++ b/server/api/files/index.get.ts
@@ -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 [];
+ }
+});
diff --git a/server/api/files/index.post.ts b/server/api/files/index.post.ts
new file mode 100644
index 0000000..bc07e2c
--- /dev/null
+++ b/server/api/files/index.post.ts
@@ -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 };
+});