From 05f20302d89811b9b2101ad63ad44ddb5fcde9a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Momme=20J=C3=BCrgensen?= Date: Wed, 17 Apr 2024 21:02:33 +0200 Subject: [PATCH 01/14] add local file provider --- .gitignore | 1 + api/config/default.json | 7 ++ api/package.json | 1 + .../migration.sql | 18 ++++ api/prisma/schema.prisma | 14 +++ api/src/config.ts | 8 ++ api/src/middleware/downloadFileLocal.ts | 36 ++++++++ api/src/middleware/index.ts | 4 + api/src/middleware/uploadFileLocal.ts | 89 +++++++++++++++++++ api/src/services/file/file.router.ts | 12 +++ api/src/services/file/fileCreate.ts | 27 ++++++ api/src/services/file/fileGetUrl.ts | 28 ++++++ api/src/services/index.ts | 2 + frontend/src/helpers/handleUpload.ts | 28 ++++++ .../views/Verwaltung/FileTest/FileList.vue | 42 +++++++++ .../src/views/Verwaltung/FileTest/routes.ts | 32 +++++++ frontend/src/views/Verwaltung/routes.ts | 2 + package-lock.json | 5 +- 18 files changed, 354 insertions(+), 2 deletions(-) create mode 100644 api/prisma/migrations/20240417185339_add_file_model/migration.sql create mode 100644 api/src/middleware/downloadFileLocal.ts create mode 100644 api/src/middleware/uploadFileLocal.ts create mode 100644 api/src/services/file/file.router.ts create mode 100644 api/src/services/file/fileCreate.ts create mode 100644 api/src/services/file/fileGetUrl.ts create mode 100644 frontend/src/helpers/handleUpload.ts create mode 100644 frontend/src/views/Verwaltung/FileTest/FileList.vue create mode 100644 frontend/src/views/Verwaltung/FileTest/routes.ts diff --git a/.gitignore b/.gitignore index f7dc85b5..6d4bf6d4 100644 --- a/.gitignore +++ b/.gitignore @@ -89,3 +89,4 @@ typings/ reports **/tsconfig.tsbuildinfo tsconfig.tsbuildinfo +uploads/ diff --git a/api/config/default.json b/api/config/default.json index 36b82d39..3f4552b2 100644 --- a/api/config/default.json +++ b/api/config/default.json @@ -30,5 +30,12 @@ "meilisearch": { "host": "meilisearch:7700", "apiKey": "xbAPupQKhkKvF176vE2JxAdPGpmWVu251Hldn6K4Z6Y" + }, + "fileProvider": "LOCAL", + "fileLOCAL": { + "path":"../uploads" + }, + "fileAZURE": { + "foo": "" } } diff --git a/api/package.json b/api/package.json index f9cbbea7..c780a760 100644 --- a/api/package.json +++ b/api/package.json @@ -41,6 +41,7 @@ "koa-session": "^6.4.0", "koa-static": "^5.0.0", "meilisearch": "^0.37.0", + "mime-types": "^2.1.35", "prom-client": "^15.0.0", "superjson": "^2.2.1", "trpc-koa-adapter": "^1.1.3", diff --git a/api/prisma/migrations/20240417185339_add_file_model/migration.sql b/api/prisma/migrations/20240417185339_add_file_model/migration.sql new file mode 100644 index 00000000..5ccdfccb --- /dev/null +++ b/api/prisma/migrations/20240417185339_add_file_model/migration.sql @@ -0,0 +1,18 @@ +-- CreateEnum +CREATE TYPE "FileProvider" AS ENUM ('LOCAL'); + +-- CreateTable +CREATE TABLE "File" ( + "id" SERIAL NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "uploaded" BOOLEAN NOT NULL DEFAULT false, + "uploadedAt" TIMESTAMP(3), + "provider" "FileProvider" NOT NULL, + "key" TEXT NOT NULL, + "mimetype" TEXT, + + CONSTRAINT "File_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "File_key_key" ON "File"("key"); diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 9f405b99..0db2d6e9 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -358,3 +358,17 @@ model CustomFieldValue { anmeldungId Int? anmeldung Anmeldung? @relation(fields: [anmeldungId], references: [id], onDelete: Cascade) } + +enum FileProvider { + LOCAL +} + +model File { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) + uploaded Boolean @default(false) + uploadedAt DateTime? + provider FileProvider + key String @unique() + mimetype String? +} diff --git a/api/src/config.ts b/api/src/config.ts index 23cb3451..315701da 100644 --- a/api/src/config.ts +++ b/api/src/config.ts @@ -1,6 +1,7 @@ import path from 'node:path' import { fileURLToPath } from 'url' +import { FileProvider } from '@prisma/client' import config from 'config' import { z } from 'zod' @@ -43,6 +44,13 @@ export const configSchema = z.strictObject({ host: z.string(), apiKey: z.string(), }), + fileProvider: z.nativeEnum(FileProvider), + fileLOCAL: z.strictObject({ + path: z.string(), + }), + fileAZURE: z.strictObject({ + foo: z.string(), + }), }) export default configSchema.parse(baseConfig) diff --git a/api/src/middleware/downloadFileLocal.ts b/api/src/middleware/downloadFileLocal.ts new file mode 100644 index 00000000..abe91b7b --- /dev/null +++ b/api/src/middleware/downloadFileLocal.ts @@ -0,0 +1,36 @@ +import * as fs from 'fs' +import * as path from 'path' + +import type { Middleware } from 'koa' +import mime from 'mime-types' + +import config from '../config' +import prisma from '../prisma' + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const downloadFileLocal: Middleware = async function (ctx, next) { + // TODO: add authentication + const fileId = parseInt(ctx.params.id) + const file = await prisma.file.findFirst({ + where: { + id: fileId, + }, + }) + if (file === null) { + ctx.response.status = 400 + ctx.response.body = { error: `File with id '${fileId}' not found` } + return + } + + if (file.provider !== 'LOCAL') { + ctx.response.status = 400 + ctx.response.body = { error: `File provider is '${file.provider}'. This endpoint is for LOCAL` } + return + } + + const uploadDir = path.join(process.cwd(), config.fileLOCAL.path) + const mimetype = file.mimetype ?? 'application/octet-stream' + ctx.set('Content-disposition', `attachment; filename=${file.id}.${mime.extension(mimetype)}`) + ctx.set('Content-type', mimetype) + ctx.response.body = fs.createReadStream(uploadDir + '/' + file.key) +} diff --git a/api/src/middleware/index.ts b/api/src/middleware/index.ts index 7f98723b..bb10bbb7 100644 --- a/api/src/middleware/index.ts +++ b/api/src/middleware/index.ts @@ -1,9 +1,13 @@ import type Router from 'koa-router' +import { downloadFileLocal } from './downloadFileLocal' import { importAnmeldungen } from './importAnmeldungen' +import { uploadFileLocal } from './uploadFileLocal' export default function addMiddlewares(router: Router) { router.post('/upload/anmeldungen', async (ctx, next) => { return await importAnmeldungen(ctx, next) }) + router.post('/upload/file/LOCAL/:id', uploadFileLocal) + router.get('/download/file/LOCAL/:id', downloadFileLocal) } diff --git a/api/src/middleware/uploadFileLocal.ts b/api/src/middleware/uploadFileLocal.ts new file mode 100644 index 00000000..cbbed838 --- /dev/null +++ b/api/src/middleware/uploadFileLocal.ts @@ -0,0 +1,89 @@ +import * as fs from 'fs/promises' +import * as path from 'path' + +import type { Middleware } from 'koa' + +import config from '../config' +import prisma from '../prisma' + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const uploadFileLocal: Middleware = async function (ctx, next) { + // TODO: add authentication + + const fileId = parseInt(ctx.params.id) + const file = await prisma.file.findFirst({ + where: { + id: fileId, + }, + }) + if (file === null) { + ctx.response.status = 400 + ctx.response.body = { error: `File with id '${fileId}' not found` } + return + } + + if (file.uploaded) { + ctx.response.status = 400 + ctx.response.body = { error: `File with id '${fileId}' already uploaded` } + return + } + + if (file.provider !== 'LOCAL') { + ctx.response.status = 400 + ctx.response.body = { error: `File provider is '${file.provider}'. This endpoint is for LOCAL` } + return + } + + const uploadDir = path.join(process.cwd(), config.fileLOCAL.path) + try { + await checkLocalUploadFolder(uploadDir) + } catch (e) { + console.error('Error while creating upload-directory\n', e) + ctx.response.status = 500 + ctx.response.body = { + error: 'Something went wrong during creation of upload-directory', + } + return + } + + const fileData = ctx.request.files?.file + if (!fileData || Array.isArray(fileData)) { + ctx.response.status = 400 + ctx.response.body = { + error: 'No or Invalid File provided', + } + return + } + + try { + await fs.copyFile(fileData.filepath, uploadDir + '/' + file.key) + } catch (e) { + console.error('Error while copy to upload-directory\n', e) + ctx.response.status = 500 + ctx.response.body = { + error: 'Something went wrong during copy to upload-directory', + } + return + } + + await prisma.file.update({ + where: { id: fileId }, + data: { + mimetype: fileData.mimetype ?? 'application/octet-stream', + uploaded: true, + uploadedAt: new Date(), + }, + }) + + ctx.response.status = 201 + ctx.response.body = { uploaded: true } +} + +async function checkLocalUploadFolder(uploadDir: string) { + try { + await fs.stat(uploadDir) + } catch (e: any) { + if (e.code === 'ENOENT') await fs.mkdir(uploadDir, { recursive: true }) + else throw e + } +} diff --git a/api/src/services/file/file.router.ts b/api/src/services/file/file.router.ts new file mode 100644 index 00000000..efde75d2 --- /dev/null +++ b/api/src/services/file/file.router.ts @@ -0,0 +1,12 @@ +/* eslint-disable prettier/prettier */ // Prettier ignored is because this file is generated +import { mergeRouters } from '../../trpc' + +import { fileCreateProcedure } from './fileCreate' +import { fileGetUrlActionProcedure } from './fileGetUrl' +// Import Routes here - do not delete this line + +export const fileRouter = mergeRouters( + fileCreateProcedure.router, + fileGetUrlActionProcedure.router, + // Add Routes here - do not delete this line +) diff --git a/api/src/services/file/fileCreate.ts b/api/src/services/file/fileCreate.ts new file mode 100644 index 00000000..59764f94 --- /dev/null +++ b/api/src/services/file/fileCreate.ts @@ -0,0 +1,27 @@ +import { randomUUID } from 'crypto' + +import z from 'zod' + +import config from '../../config' +import prisma from '../../prisma' +import { defineProcedure } from '../../types/defineProcedure' + +export const fileCreateProcedure = defineProcedure({ + key: 'fileCreate', + method: 'mutation', + protection: { type: 'public' }, // TODO: authentication + inputSchema: z.undefined(), + async handler() { + return prisma.file.create({ + data: { + provider: config.fileProvider, + key: randomUUID(), + }, + select: { + id: true, + provider: true, + uploaded: true, + }, + }) + }, +}) diff --git a/api/src/services/file/fileGetUrl.ts b/api/src/services/file/fileGetUrl.ts new file mode 100644 index 00000000..646ee7ab --- /dev/null +++ b/api/src/services/file/fileGetUrl.ts @@ -0,0 +1,28 @@ +import z from 'zod' + +import config from '../../config' +import prisma from '../../prisma' +import { defineProcedure } from '../../types/defineProcedure' + +export const fileGetUrlActionProcedure = defineProcedure({ + key: 'fileGetUrl', + method: 'query', + protection: { type: 'public' }, // TODO: authentication + inputSchema: z.strictObject({ + id: z.number().int(), + }), + async handler(options) { + const file = await prisma.file.findUniqueOrThrow({ + where: { + id: options.input.id, + }, + select: { + id: true, + provider: true, + uploaded: true, + }, + }) + if (!file.uploaded) throw new Error('File is not uploaded') + return new URL(`/api/download/file/${file.provider}/${file.id}`, config.clientUrl).href + }, +}) diff --git a/api/src/services/index.ts b/api/src/services/index.ts index 1e226320..072a6746 100644 --- a/api/src/services/index.ts +++ b/api/src/services/index.ts @@ -5,6 +5,7 @@ import { activityRouter } from './activity/activity.routes' import { anmeldungRouter } from './anmeldung/anmeldung.router' import { authenticationRouter } from './authentication/authentication.router' import { customFieldsRouter } from './customFields/customFields.router' +import { fileRouter } from './file/file.router' import { gliederungRouter } from './gliederung/gliederung.router' import { ortRouter } from './ort/ort.router' import { personRouter } from './person/person.router' @@ -27,5 +28,6 @@ export const serviceRouter = router({ search: searchRouter, system: systemRouter, customFields: customFieldsRouter, + file: fileRouter, // Add Routers here - do not delete this line }) diff --git a/frontend/src/helpers/handleUpload.ts b/frontend/src/helpers/handleUpload.ts new file mode 100644 index 00000000..ddbe6593 --- /dev/null +++ b/frontend/src/helpers/handleUpload.ts @@ -0,0 +1,28 @@ +import { apiClient } from '@/api' + +/** + * Upload file + * @param file + */ +export const handleUpload = async (file: File) => { + const dbFile = await apiClient.file.fileCreate.mutate() + + const formData = new FormData() + formData.append('file', file) + + if (dbFile.provider === 'LOCAL') { + const response = await fetch(`/api/upload/file/LOCAL/${dbFile.id}`, { + method: 'POST', + headers: { + // Authorization: 'Bearer ' + this.token, // TODO: authentication + }, + body: formData, + }) + if (response.status != 201) throw new Error(`Failed to upload file: ${await response.text()}`) + + return { + id: dbFile.id, + url: await apiClient.file.fileGetUrl.query({ id: dbFile.id }), + } + } else throw new Error(`File provider '${dbFile.provider}' is not supported.`) +} diff --git a/frontend/src/views/Verwaltung/FileTest/FileList.vue b/frontend/src/views/Verwaltung/FileTest/FileList.vue new file mode 100644 index 00000000..ca5c6967 --- /dev/null +++ b/frontend/src/views/Verwaltung/FileTest/FileList.vue @@ -0,0 +1,42 @@ + + + + diff --git a/frontend/src/views/Verwaltung/FileTest/routes.ts b/frontend/src/views/Verwaltung/FileTest/routes.ts new file mode 100644 index 00000000..32dc3a82 --- /dev/null +++ b/frontend/src/views/Verwaltung/FileTest/routes.ts @@ -0,0 +1,32 @@ +import type { Route } from '@/router' + +const fileRoutes: Route[] = [ + { + name: 'Verwaltung File', + path: 'file', + redirect: { name: 'Verwaltung Alle File' }, + meta: { + breadcrumbs: [ + { + text: 'File', + }, + ], + }, + children: [ + { + name: 'Verwaltung Alle File', + path: 'liste', + component: () => import('./FileList.vue'), + meta: { + breadcrumbs: [ + { + text: 'Übersicht', + }, + ], + }, + }, + ], + }, +] + +export default fileRoutes diff --git a/frontend/src/views/Verwaltung/routes.ts b/frontend/src/views/Verwaltung/routes.ts index 747bf38f..5e2154ef 100644 --- a/frontend/src/views/Verwaltung/routes.ts +++ b/frontend/src/views/Verwaltung/routes.ts @@ -1,4 +1,5 @@ import routesAccount from './Accounts/routes' +import fileRoutes from './FileTest/routes' import gliederungRoutes from './Gliederungen/routes' import orteRoutes from './Orte/routes' import routesPerson from './Persons/routes' @@ -24,6 +25,7 @@ const routesVerwaltung: Route[] = [ ...veranstaltungRoutes, ...orteRoutes, ...routesAccount, + ...fileRoutes, ], }, ] diff --git a/package-lock.json b/package-lock.json index 699e765f..e6724c1d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codeanker-project", - "version": "1.5.0", + "version": "1.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codeanker-project", - "version": "1.5.0", + "version": "1.8.0", "workspaces": [ "api", "frontend", @@ -64,6 +64,7 @@ "koa-session": "^6.4.0", "koa-static": "^5.0.0", "meilisearch": "^0.37.0", + "mime-types": "^2.1.35", "prom-client": "^15.0.0", "superjson": "^2.2.1", "trpc-koa-adapter": "^1.1.3", From 56ebc328edf2e7701b09c8b4e1046200fdfb2020 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Momme=20J=C3=BCrgensen?= Date: Wed, 17 Apr 2024 23:09:50 +0200 Subject: [PATCH 02/14] add UnterveranstalltungDocument + add manage document in unterveranstalltung edit --- .../migrations/20240417192339_/migration.sql | 28 ++++ api/prisma/schema.prisma | 142 ++++++++++-------- .../unterveranstaltungGliederungGet.ts | 8 + .../unterveranstaltungGliederungPatch.ts | 42 +++++- .../unterveranstaltungVerwaltungGet.ts | 8 + .../unterveranstaltungVerwaltungPatch.ts | 41 ++++- frontend/src/components/DownloadLink.vue | 31 ++++ .../FormUnterveranstaltungGeneral.vue | 94 +++++++++++- frontend/src/helpers/handleUpload.ts | 1 - 9 files changed, 325 insertions(+), 70 deletions(-) create mode 100644 api/prisma/migrations/20240417192339_/migration.sql create mode 100644 frontend/src/components/DownloadLink.vue diff --git a/api/prisma/migrations/20240417192339_/migration.sql b/api/prisma/migrations/20240417192339_/migration.sql new file mode 100644 index 00000000..5adc618c --- /dev/null +++ b/api/prisma/migrations/20240417192339_/migration.sql @@ -0,0 +1,28 @@ +/* + Warnings: + + - A unique constraint covering the columns `[id]` on the table `File` will be added. If there are existing duplicate values, this will fail. + +*/ +-- CreateTable +CREATE TABLE "UnterveranstaltungDocument" ( + "id" SERIAL NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "unterveranstaltungId" INTEGER NOT NULL, + "fileId" INTEGER NOT NULL, + + CONSTRAINT "UnterveranstaltungDocument_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "UnterveranstaltungDocument_fileId_key" ON "UnterveranstaltungDocument"("fileId"); + +-- CreateIndex +CREATE UNIQUE INDEX "File_id_key" ON "File"("id"); + +-- AddForeignKey +ALTER TABLE "UnterveranstaltungDocument" ADD CONSTRAINT "UnterveranstaltungDocument_unterveranstaltungId_fkey" FOREIGN KEY ("unterveranstaltungId") REFERENCES "Unterveranstaltung"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "UnterveranstaltungDocument" ADD CONSTRAINT "UnterveranstaltungDocument_fileId_fkey" FOREIGN KEY ("fileId") REFERENCES "File"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 0db2d6e9..b312c8bb 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -2,7 +2,7 @@ // learn more about it in the docs: https://pris.ly/d/prisma-schema generator client { - provider = "prisma-client-js" + provider = "prisma-client-js" previewFeatures = ["fullTextSearch"] } @@ -112,17 +112,17 @@ enum Konfektionsgroesse { } model Hostname { - id Int @id @default(autoincrement()) - hostname String + id Int @id @default(autoincrement()) + hostname String veranstaltung Veranstaltung[] } model Notfallkontakt { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) firstname String lastname String telefon String - istErziehungsberechtigt Boolean @default(false) + istErziehungsberechtigt Boolean @default(false) personId Int person Person @relation(fields: [personId], references: [id], onDelete: Cascade) } @@ -157,7 +157,7 @@ model Person { model Account { id Int @id @default(autoincrement()) email String @unique - dlrgOauthId String? @unique + dlrgOauthId String? @unique password String? role Role personId Int @unique @@ -238,26 +238,26 @@ model Ort { } model Veranstaltung { - id Int @id @default(autoincrement()) - name String - beginn DateTime @db.Date - ende DateTime @db.Date - meldebeginn DateTime - meldeschluss DateTime - ortId Int? - ort Ort? @relation(fields: [ortId], references: [id], onDelete: SetNull) - maxTeilnehmende Int - teilnahmegebuehr Int - unterveranstaltungen Unterveranstaltung[] - mahlzeiten Mahlzeit[] - beschreibung String? - datenschutz String? - teilnahmeBedingungen String? - teilnahmeBedingungenPublic String? - zielgruppe String? - hostnameId Int? - hostname Hostname? @relation(fields: [hostnameId], references: [id]) - customFields CustomField[] + id Int @id @default(autoincrement()) + name String + beginn DateTime @db.Date + ende DateTime @db.Date + meldebeginn DateTime + meldeschluss DateTime + ortId Int? + ort Ort? @relation(fields: [ortId], references: [id], onDelete: SetNull) + maxTeilnehmende Int + teilnahmegebuehr Int + unterveranstaltungen Unterveranstaltung[] + mahlzeiten Mahlzeit[] + beschreibung String? + datenschutz String? + teilnahmeBedingungen String? + teilnahmeBedingungenPublic String? + zielgruppe String? + hostnameId Int? + hostname Hostname? @relation(fields: [hostnameId], references: [id]) + customFields CustomField[] } enum MahlzeitType { @@ -281,20 +281,31 @@ enum UnterveranstaltungType { } model Unterveranstaltung { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) maxTeilnehmende Int teilnahmegebuehr Int meldebeginn DateTime meldeschluss DateTime veranstaltungId Int - veranstaltung Veranstaltung @relation(fields: [veranstaltungId], references: [id], onDelete: Cascade) + veranstaltung Veranstaltung @relation(fields: [veranstaltungId], references: [id], onDelete: Cascade) gliederungId Int - gliederung Gliederung @relation(fields: [gliederungId], references: [id], onDelete: Cascade) + gliederung Gliederung @relation(fields: [gliederungId], references: [id], onDelete: Cascade) Anmeldung Anmeldung[] beschreibung String? bedingungen String? - type UnterveranstaltungType @default(GLIEDERUNG) + type UnterveranstaltungType @default(GLIEDERUNG) customFields CustomField[] + documents UnterveranstaltungDocument[] +} + +model UnterveranstaltungDocument { + id Int @id @default(autoincrement()) + name String + description String? + unterveranstaltungId Int + unterveranstaltung Unterveranstaltung? @relation(fields: [unterveranstaltungId], references: [id], onDelete: Cascade) + file File @relation(fields: [fileId], references: [id]) + fileId Int @unique } enum ActivityType { @@ -306,15 +317,15 @@ enum ActivityType { } model Activity { - id Int @id @default(autoincrement()) - createdAt DateTime @default(now()) - type ActivityType - description String? - subjectType String - subjectId Int? - causerId Int? - causer Account? @relation(fields: [causerId], references: [id], onDelete: SetNull) - metadata Json @default("{}") + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) + type ActivityType + description String? + subjectType String + subjectId Int? + causerId Int? + causer Account? @relation(fields: [causerId], references: [id], onDelete: SetNull) + metadata Json @default("{}") } enum CustomFieldType { @@ -335,28 +346,28 @@ enum CustomFieldPosition { } model CustomField { - id Int @id @default(autoincrement()) - name String - description String? - type CustomFieldType - required Boolean @default(false) - options String[] - role Role[] - values CustomFieldValue[] - positions CustomFieldPosition[] - veranstaltungId Int? - unterveranstaltungId Int? - veranstaltung Veranstaltung? @relation(fields: [veranstaltungId], references: [id], onDelete: Cascade) - unterveranstaltung Unterveranstaltung? @relation(fields: [unterveranstaltungId], references: [id], onDelete: Cascade) + id Int @id @default(autoincrement()) + name String + description String? + type CustomFieldType + required Boolean @default(false) + options String[] + role Role[] + values CustomFieldValue[] + positions CustomFieldPosition[] + veranstaltungId Int? + unterveranstaltungId Int? + veranstaltung Veranstaltung? @relation(fields: [veranstaltungId], references: [id], onDelete: Cascade) + unterveranstaltung Unterveranstaltung? @relation(fields: [unterveranstaltungId], references: [id], onDelete: Cascade) } model CustomFieldValue { - id Int @id @default(autoincrement()) - value Json @default("{}") - fieldId Int - field CustomField @relation(fields: [fieldId], references: [id], onDelete: Cascade) - anmeldungId Int? - anmeldung Anmeldung? @relation(fields: [anmeldungId], references: [id], onDelete: Cascade) + id Int @id @default(autoincrement()) + value Json @default("{}") + fieldId Int + field CustomField @relation(fields: [fieldId], references: [id], onDelete: Cascade) + anmeldungId Int? + anmeldung Anmeldung? @relation(fields: [anmeldungId], references: [id], onDelete: Cascade) } enum FileProvider { @@ -364,11 +375,12 @@ enum FileProvider { } model File { - id Int @id @default(autoincrement()) - createdAt DateTime @default(now()) - uploaded Boolean @default(false) - uploadedAt DateTime? - provider FileProvider - key String @unique() - mimetype String? + id Int @id @unique @default(autoincrement()) + createdAt DateTime @default(now()) + uploaded Boolean @default(false) + uploadedAt DateTime? + provider FileProvider + key String @unique() + mimetype String? + UnterveranstaltungDocument UnterveranstaltungDocument? } diff --git a/api/src/services/unterveranstaltung/unterveranstaltungGliederungGet.ts b/api/src/services/unterveranstaltung/unterveranstaltungGliederungGet.ts index 75b9297f..a6a5f53b 100644 --- a/api/src/services/unterveranstaltung/unterveranstaltungGliederungGet.ts +++ b/api/src/services/unterveranstaltung/unterveranstaltungGliederungGet.ts @@ -43,6 +43,14 @@ export const unterveranstaltungGliederungGetProcedure = defineProcedure({ }, }, gliederung: true, + documents: { + select: { + id: true, + name: true, + description: true, + fileId: true, + }, + }, }, }) }, diff --git a/api/src/services/unterveranstaltung/unterveranstaltungGliederungPatch.ts b/api/src/services/unterveranstaltung/unterveranstaltungGliederungPatch.ts index 9bdf823a..831934ea 100644 --- a/api/src/services/unterveranstaltung/unterveranstaltungGliederungPatch.ts +++ b/api/src/services/unterveranstaltung/unterveranstaltungGliederungPatch.ts @@ -17,16 +17,56 @@ export const unterveranstaltungGliederungPatchProcedure = defineProcedure({ meldeschluss: z.date().optional(), beschreibung: z.string().optional(), bedingungen: z.string().optional(), + addDocuments: z + .array( + z.strictObject({ + name: z.string(), + fileId: z.number().int(), + }) + ) + .optional(), + updateDocuments: z.array(z.strictObject({ id: z.number().int(), name: z.string() })).optional(), + deleteDocumentIds: z.array(z.number().int()).optional(), }), }), async handler(options) { const gliederung = await getGliederungRequireAdmin(options.ctx.accountId) + + // Documents create, update, delete + const documents: { + createMany?: { data: { name: string; fileId: number }[] } + updateMany?: { where: { id: number }; data: { name: string } }[] + deleteMany?: { id: number }[] + } = {} + if (options.input.data.addDocuments) { + documents.createMany = { + data: options.input.data.addDocuments, + } + } + if (options.input.data.updateDocuments) { + documents.updateMany = options.input.data.updateDocuments.map((doc) => ({ + where: { id: doc.id }, + data: { name: doc.name }, + })) + } + if (options.input.data.deleteDocumentIds) { + documents.deleteMany = options.input.data.deleteDocumentIds.map((id) => ({ id })) + } + return prisma.unterveranstaltung.update({ where: { id: options.input.id, gliederungId: gliederung.id, }, - data: options.input.data, + data: { + maxTeilnehmende: options.input.data.maxTeilnehmende, + teilnahmegebuehr: options.input.data.teilnahmegebuehr, + meldebeginn: options.input.data.meldebeginn, + meldeschluss: options.input.data.meldeschluss, + beschreibung: options.input.data.beschreibung, + bedingungen: options.input.data.bedingungen, + documents: documents, + }, select: { id: true, }, diff --git a/api/src/services/unterveranstaltung/unterveranstaltungVerwaltungGet.ts b/api/src/services/unterveranstaltung/unterveranstaltungVerwaltungGet.ts index 1f23c81a..b6d446ea 100644 --- a/api/src/services/unterveranstaltung/unterveranstaltungVerwaltungGet.ts +++ b/api/src/services/unterveranstaltung/unterveranstaltungVerwaltungGet.ts @@ -42,6 +42,14 @@ export const unterveranstaltungVerwaltungGetProcedure = defineProcedure({ hostname: true, }, }, + documents: { + select: { + id: true, + name: true, + description: true, + fileId: true, + }, + }, }, }) }, diff --git a/api/src/services/unterveranstaltung/unterveranstaltungVerwaltungPatch.ts b/api/src/services/unterveranstaltung/unterveranstaltungVerwaltungPatch.ts index a7fe5370..3d66196e 100644 --- a/api/src/services/unterveranstaltung/unterveranstaltungVerwaltungPatch.ts +++ b/api/src/services/unterveranstaltung/unterveranstaltungVerwaltungPatch.ts @@ -16,14 +16,53 @@ export const unterveranstaltungVerwaltungPatchProcedure = defineProcedure({ meldeschluss: z.date().optional(), beschreibung: z.string().optional(), bedingungen: z.string().optional(), + addDocuments: z + .array( + z.strictObject({ + name: z.string(), + fileId: z.number().int(), + }) + ) + .optional(), + updateDocuments: z.array(z.strictObject({ id: z.number().int(), name: z.string() })).optional(), + deleteDocumentIds: z.array(z.number().int()).optional(), }), }), async handler(options) { + // Documents create, update, delete + const documents: { + createMany?: { data: { name: string; fileId: number }[] } + updateMany?: { where: { id: number }; data: { name: string } }[] + deleteMany?: { id: number }[] + } = {} + if (options.input.data.addDocuments) { + documents.createMany = { + data: options.input.data.addDocuments, + } + } + if (options.input.data.updateDocuments) { + documents.updateMany = options.input.data.updateDocuments.map((doc) => ({ + where: { id: doc.id }, + data: { name: doc.name }, + })) + } + if (options.input.data.deleteDocumentIds) { + documents.deleteMany = options.input.data.deleteDocumentIds.map((id) => ({ id })) + } + return prisma.unterveranstaltung.update({ where: { id: options.input.id, }, - data: options.input.data, + data: { + maxTeilnehmende: options.input.data.maxTeilnehmende, + teilnahmegebuehr: options.input.data.teilnahmegebuehr, + meldebeginn: options.input.data.meldebeginn, + meldeschluss: options.input.data.meldeschluss, + beschreibung: options.input.data.beschreibung, + bedingungen: options.input.data.bedingungen, + documents: documents, + }, select: { id: true, }, diff --git a/frontend/src/components/DownloadLink.vue b/frontend/src/components/DownloadLink.vue new file mode 100644 index 00000000..4588a7d6 --- /dev/null +++ b/frontend/src/components/DownloadLink.vue @@ -0,0 +1,31 @@ + + + diff --git a/frontend/src/components/forms/unterveranstaltung/FormUnterveranstaltungGeneral.vue b/frontend/src/components/forms/unterveranstaltung/FormUnterveranstaltungGeneral.vue index a0333616..c9ac98f1 100644 --- a/frontend/src/components/forms/unterveranstaltung/FormUnterveranstaltungGeneral.vue +++ b/frontend/src/components/forms/unterveranstaltung/FormUnterveranstaltungGeneral.vue @@ -1,4 +1,5 @@ diff --git a/frontend/src/views/PublicAnmeldung/PublicAusschreibungView.vue b/frontend/src/views/PublicAnmeldung/PublicAusschreibungView.vue index f2cdbd3d..1447cf16 100644 --- a/frontend/src/views/PublicAnmeldung/PublicAusschreibungView.vue +++ b/frontend/src/views/PublicAnmeldung/PublicAusschreibungView.vue @@ -5,6 +5,7 @@ import { computed } from 'vue' import { useRoute, useRouter } from 'vue-router' import { apiClient } from '@/api' +import DownloadLink from '@/components/DownloadLink.vue' import PublicFooter from '@/components/LayoutComponents/PublicFooter.vue' import PublicHeader from '@/components/LayoutComponents/PublicHeader.vue' import Button from '@/components/UIComponents/Button.vue' @@ -91,6 +92,23 @@ const isClosed = computed(() => dayjs().isAfter(unterveranstaltung.value?.meldes v-html="unterveranstaltung?.beschreibung" > + +
From 2e52bd7757fd426dd64ac9415f51e3cdf63b56cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Momme=20J=C3=BCrgensen?= Date: Wed, 17 Apr 2024 23:53:39 +0200 Subject: [PATCH 06/14] rename fileProvider --- api/config/default.json | 2 +- api/src/config.ts | 2 +- api/src/services/file/fileCreate.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/api/config/default.json b/api/config/default.json index 3f4552b2..64c478d8 100644 --- a/api/config/default.json +++ b/api/config/default.json @@ -31,7 +31,7 @@ "host": "meilisearch:7700", "apiKey": "xbAPupQKhkKvF176vE2JxAdPGpmWVu251Hldn6K4Z6Y" }, - "fileProvider": "LOCAL", + "defaultFileProvider": "LOCAL", "fileLOCAL": { "path":"../uploads" }, diff --git a/api/src/config.ts b/api/src/config.ts index 315701da..e39e6ecb 100644 --- a/api/src/config.ts +++ b/api/src/config.ts @@ -44,7 +44,7 @@ export const configSchema = z.strictObject({ host: z.string(), apiKey: z.string(), }), - fileProvider: z.nativeEnum(FileProvider), + defaultFileProvider: z.nativeEnum(FileProvider), fileLOCAL: z.strictObject({ path: z.string(), }), diff --git a/api/src/services/file/fileCreate.ts b/api/src/services/file/fileCreate.ts index 59764f94..6d6c2da4 100644 --- a/api/src/services/file/fileCreate.ts +++ b/api/src/services/file/fileCreate.ts @@ -14,7 +14,7 @@ export const fileCreateProcedure = defineProcedure({ async handler() { return prisma.file.create({ data: { - provider: config.fileProvider, + provider: config.defaultFileProvider, key: randomUUID(), }, select: { From f7a699433b02e84a05caaffbcdf91b00298d594a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Momme=20J=C3=BCrgensen?= Date: Thu, 18 Apr 2024 22:18:36 +0200 Subject: [PATCH 07/14] WIP azure file provider --- api/config/default.json | 6 +- api/package.json | 1 + .../migrations/20240418192004_/migration.sql | 2 + api/prisma/schema.prisma | 1 + api/src/azureStorage.ts | 28 +++ api/src/config.ts | 4 +- api/src/services/file/fileCreate.ts | 37 ++- api/src/services/file/fileGetUrl.ts | 20 +- frontend/package.json | 1 + frontend/src/helpers/handleUpload.ts | 16 +- package-lock.json | 219 ++++++++++++++++++ 11 files changed, 322 insertions(+), 13 deletions(-) create mode 100644 api/prisma/migrations/20240418192004_/migration.sql create mode 100644 api/src/azureStorage.ts diff --git a/api/config/default.json b/api/config/default.json index 64c478d8..781de3e5 100644 --- a/api/config/default.json +++ b/api/config/default.json @@ -31,11 +31,13 @@ "host": "meilisearch:7700", "apiKey": "xbAPupQKhkKvF176vE2JxAdPGpmWVu251Hldn6K4Z6Y" }, - "defaultFileProvider": "LOCAL", + "defaultFileProvider": "AZURE", "fileLOCAL": { "path":"../uploads" }, "fileAZURE": { - "foo": "" + "account": "", + "accountKey": "", + "container": "" } } diff --git a/api/package.json b/api/package.json index c780a760..bad75d14 100644 --- a/api/package.json +++ b/api/package.json @@ -23,6 +23,7 @@ "seed": "tsx prisma/seeders/index.ts" }, "dependencies": { + "@azure/storage-blob": "^12.17.0", "@faker-js/faker": "^8.4.1", "@koa/cors": "^5.0.0", "@koa/router": "^12.0.1", diff --git a/api/prisma/migrations/20240418192004_/migration.sql b/api/prisma/migrations/20240418192004_/migration.sql new file mode 100644 index 00000000..16bc6d86 --- /dev/null +++ b/api/prisma/migrations/20240418192004_/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "FileProvider" ADD VALUE 'AZURE'; diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 8806d5ed..53e5ab3b 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -372,6 +372,7 @@ model CustomFieldValue { enum FileProvider { LOCAL + AZURE } model File { diff --git a/api/src/azureStorage.ts b/api/src/azureStorage.ts new file mode 100644 index 00000000..5e2dc36f --- /dev/null +++ b/api/src/azureStorage.ts @@ -0,0 +1,28 @@ +import { BlobServiceClient, StorageSharedKeyCredential } from '@azure/storage-blob' + +import config from './config' + +const isAzureConfigured = + config.fileAZURE.account !== '' && config.fileAZURE.accountKey !== '' && config.fileAZURE.container !== '' + +async function init() { + if (!isAzureConfigured) return null + const account = config.fileAZURE.account + const accountKey = config.fileAZURE.accountKey + const sharedKeyCredential = new StorageSharedKeyCredential(account, accountKey) + + const blobServiceClient = new BlobServiceClient(`https://${account}.blob.core.windows.net`, sharedKeyCredential) + + // Check if container exists, if not create it + const containerClient = blobServiceClient.getContainerClient(config.fileAZURE.container) + if (!(await containerClient.exists())) await containerClient.create() + return blobServiceClient +} + +export let azureStorage: BlobServiceClient | null = null + +init() + .then((blobServiceClient) => (azureStorage = blobServiceClient)) + .catch((e) => { + console.error('Failed to initialize Azure Blob Storage', e) + }) diff --git a/api/src/config.ts b/api/src/config.ts index e39e6ecb..f05ba668 100644 --- a/api/src/config.ts +++ b/api/src/config.ts @@ -49,7 +49,9 @@ export const configSchema = z.strictObject({ path: z.string(), }), fileAZURE: z.strictObject({ - foo: z.string(), + account: z.string(), + accountKey: z.string(), + container: z.string(), }), }) diff --git a/api/src/services/file/fileCreate.ts b/api/src/services/file/fileCreate.ts index 6d6c2da4..49a700a9 100644 --- a/api/src/services/file/fileCreate.ts +++ b/api/src/services/file/fileCreate.ts @@ -1,7 +1,10 @@ import { randomUUID } from 'crypto' +import { BlobSASPermissions } from '@azure/storage-blob' +import dayjs from 'dayjs' import z from 'zod' +import { azureStorage } from '../../azureStorage' import config from '../../config' import prisma from '../../prisma' import { defineProcedure } from '../../types/defineProcedure' @@ -10,12 +13,18 @@ export const fileCreateProcedure = defineProcedure({ key: 'fileCreate', method: 'mutation', protection: { type: 'public' }, // TODO: authentication - inputSchema: z.undefined(), - async handler() { - return prisma.file.create({ + inputSchema: z.strictObject({ + mimetype: z.string(), + }), + async handler(options) { + const provider = config.defaultFileProvider + const key: string = randomUUID() + + const file = await prisma.file.create({ data: { - provider: config.defaultFileProvider, - key: randomUUID(), + provider: provider, + key: key, + mimetype: options.input.mimetype, }, select: { id: true, @@ -23,5 +32,23 @@ export const fileCreateProcedure = defineProcedure({ uploaded: true, }, }) + + let azureUploadUrl: string | null = null + if (file.provider === 'AZURE' && azureStorage !== null) { + const containerClient = azureStorage.getContainerClient(config.fileAZURE.container) + const blockBlobClient = containerClient.getBlockBlobClient(key) + const permissions = BlobSASPermissions.from({ read: true, write: true, add: true, create: true }) + azureUploadUrl = await blockBlobClient.generateSasUrl({ + startsOn: dayjs().subtract(5, 'minute').toDate(), + expiresOn: dayjs().add(20, 'minute').toDate(), + permissions: permissions, + contentType: options.input.mimetype, + }) + } + + return { + ...file, + azureUploadUrl, + } }, }) diff --git a/api/src/services/file/fileGetUrl.ts b/api/src/services/file/fileGetUrl.ts index 646ee7ab..76d232d2 100644 --- a/api/src/services/file/fileGetUrl.ts +++ b/api/src/services/file/fileGetUrl.ts @@ -1,9 +1,14 @@ +import { BlobSASPermissions } from '@azure/storage-blob' +import dayjs from 'dayjs' import z from 'zod' +import { azureStorage } from '../../azureStorage' import config from '../../config' import prisma from '../../prisma' import { defineProcedure } from '../../types/defineProcedure' +const downloadUrlLifespan = 60 * 60 // 1 hour + export const fileGetUrlActionProcedure = defineProcedure({ key: 'fileGetUrl', method: 'query', @@ -20,9 +25,22 @@ export const fileGetUrlActionProcedure = defineProcedure({ id: true, provider: true, uploaded: true, + key: true, }, }) if (!file.uploaded) throw new Error('File is not uploaded') - return new URL(`/api/download/file/${file.provider}/${file.id}`, config.clientUrl).href + + if (file.provider === 'LOCAL') + return new URL(`/api/download/file/${file.provider}/${file.id}`, config.clientUrl).href + if (file.provider === 'AZURE' && azureStorage !== null) { + const containerClient = azureStorage.getContainerClient(config.fileAZURE.container) + const blockBlobClient = containerClient.getBlockBlobClient(file.key) + return await blockBlobClient.generateSasUrl({ + startsOn: dayjs().subtract(5, 'minute').toDate(), + expiresOn: dayjs().add(downloadUrlLifespan, 'seconds').toDate(), + permissions: BlobSASPermissions.from({ read: true }), + }) + } + return null }, }) diff --git a/frontend/package.json b/frontend/package.json index b2a5a853..20e8d404 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,6 +7,7 @@ "serve": "vite preview" }, "dependencies": { + "@azure/storage-blob": "^12.17.0", "@codeanker/api": "*", "@codeanker/cookies": "^1.0.0", "@codeanker/core-grid": "file:../vendor/codeanker-core-grid-1.0.0.tgz", diff --git a/frontend/src/helpers/handleUpload.ts b/frontend/src/helpers/handleUpload.ts index 9c3e0e67..c93045fb 100644 --- a/frontend/src/helpers/handleUpload.ts +++ b/frontend/src/helpers/handleUpload.ts @@ -1,3 +1,5 @@ +import { BlockBlobClient } from '@azure/storage-blob' + import { apiClient } from '@/api' /** @@ -5,7 +7,7 @@ import { apiClient } from '@/api' * @param file */ export const handleUpload = async (file: File) => { - const dbFile = await apiClient.file.fileCreate.mutate() + const dbFile = await apiClient.file.fileCreate.mutate({ mimetype: file.type }) const formData = new FormData() formData.append('file', file) @@ -20,8 +22,14 @@ export const handleUpload = async (file: File) => { }) if (response.status != 201) throw new Error(`Failed to upload file: ${await response.text()}`) - return { - id: dbFile.id, - } + return { id: dbFile.id } + } else if (dbFile.provider === 'AZURE') { + const blobServiceClient = new BlockBlobClient(dbFile.azureUploadUrl as string) + await blobServiceClient.upload(file, file.size, { + blobHTTPHeaders: { + blobContentType: file.type, + }, + }) + return { id: dbFile.id } } else throw new Error(`File provider '${dbFile.provider}' is not supported.`) } diff --git a/package-lock.json b/package-lock.json index e6724c1d..8bd73bcc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,6 +46,7 @@ "name": "@codeanker/api", "hasInstallScript": true, "dependencies": { + "@azure/storage-blob": "^12.17.0", "@faker-js/faker": "^8.4.1", "@koa/cors": "^5.0.0", "@koa/router": "^12.0.1", @@ -112,6 +113,7 @@ "name": "@codeanker/frontend", "version": "0.0.0", "dependencies": { + "@azure/storage-blob": "^12.17.0", "@codeanker/api": "*", "@codeanker/cookies": "^1.0.0", "@codeanker/core-grid": "file:../vendor/codeanker-core-grid-1.0.0.tgz", @@ -325,6 +327,165 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@azure/abort-controller": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-1.1.0.tgz", + "integrity": "sha512-TrRLIoSQVzfAJX9H1JeFjzAoDGcoK1IYX1UImfceTZpsyYfWr09Ss1aHW1y5TrrR3iq6RZLBwJ3E24uwPhwahw==", + "dependencies": { + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@azure/core-auth": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.7.2.tgz", + "integrity": "sha512-Igm/S3fDYmnMq1uKS38Ae1/m37B3zigdlZw+kocwEhh5GjyKjPrXKO2J6rzpC1wAxrNil/jX9BJRqBshyjnF3g==", + "dependencies": { + "@azure/abort-controller": "^2.0.0", + "@azure/core-util": "^1.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-auth/node_modules/@azure/abort-controller": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", + "integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-http": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@azure/core-http/-/core-http-3.0.4.tgz", + "integrity": "sha512-Fok9VVhMdxAFOtqiiAtg74fL0UJkt0z3D+ouUUxcRLzZNBioPRAMJFVxiWoJljYpXsRi4GDQHzQHDc9AiYaIUQ==", + "dependencies": { + "@azure/abort-controller": "^1.0.0", + "@azure/core-auth": "^1.3.0", + "@azure/core-tracing": "1.0.0-preview.13", + "@azure/core-util": "^1.1.1", + "@azure/logger": "^1.0.0", + "@types/node-fetch": "^2.5.0", + "@types/tunnel": "^0.0.3", + "form-data": "^4.0.0", + "node-fetch": "^2.6.7", + "process": "^0.11.10", + "tslib": "^2.2.0", + "tunnel": "^0.0.6", + "uuid": "^8.3.0", + "xml2js": "^0.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@azure/core-lro": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/@azure/core-lro/-/core-lro-2.7.2.tgz", + "integrity": "sha512-0YIpccoX8m/k00O7mDDMdJpbr6mf1yWo2dfmxt5A8XVZVVMz2SSKaEbMCeJRvgQ0IaSlqhjT47p4hVIRRy90xw==", + "dependencies": { + "@azure/abort-controller": "^2.0.0", + "@azure/core-util": "^1.2.0", + "@azure/logger": "^1.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-lro/node_modules/@azure/abort-controller": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", + "integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-paging": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/@azure/core-paging/-/core-paging-1.6.2.tgz", + "integrity": "sha512-YKWi9YuCU04B55h25cnOYZHxXYtEvQEbKST5vqRga7hWY9ydd3FZHdeQF8pyh+acWZvppw13M/LMGx0LABUVMA==", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-tracing": { + "version": "1.0.0-preview.13", + "resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.0.0-preview.13.tgz", + "integrity": "sha512-KxDlhXyMlh2Jhj2ykX6vNEU0Vou4nHr025KoSEiz7cS3BNiHNaZcdECk/DmLkEB0as5T7b/TpRcehJ5yV6NeXQ==", + "dependencies": { + "@opentelemetry/api": "^1.0.1", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@azure/core-util": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.9.0.tgz", + "integrity": "sha512-AfalUQ1ZppaKuxPPMsFEUdX6GZPB3d9paR9d/TTL7Ow2De8cJaC7ibi7kWVlFAVPCYo31OcnGymc0R89DX8Oaw==", + "dependencies": { + "@azure/abort-controller": "^2.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-util/node_modules/@azure/abort-controller": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", + "integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/logger": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@azure/logger/-/logger-1.1.2.tgz", + "integrity": "sha512-l170uE7bsKpIU6B/giRc9i4NI0Mj+tANMMMxf7Zi/5cKzEqPayP7+X1WPrG7e+91JgY8N+7K7nF2WOi7iVhXvg==", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/storage-blob": { + "version": "12.17.0", + "resolved": "https://registry.npmjs.org/@azure/storage-blob/-/storage-blob-12.17.0.tgz", + "integrity": "sha512-sM4vpsCpcCApagRW5UIjQNlNylo02my2opgp0Emi8x888hZUvJ3dN69Oq20cEGXkMUWnoCrBaB0zyS3yeB87sQ==", + "dependencies": { + "@azure/abort-controller": "^1.0.0", + "@azure/core-http": "^3.0.0", + "@azure/core-lro": "^2.2.0", + "@azure/core-paging": "^1.1.1", + "@azure/core-tracing": "1.0.0-preview.13", + "@azure/logger": "^1.0.0", + "events": "^3.0.0", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@babel/parser": { "version": "7.24.1", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.1.tgz", @@ -2754,6 +2915,15 @@ "undici-types": "~5.26.4" } }, + "node_modules/@types/node-fetch": { + "version": "2.6.11", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.11.tgz", + "integrity": "sha512-24xFj9R5+rfQJLRyM56qh+wnVSYhyXC2tkoBndtY0U+vubqNsYXGjufB2nn8Q6gt0LrARwL6UBtMCSVCwl4B1g==", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.0" + } + }, "node_modules/@types/qs": { "version": "6.9.14", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.14.tgz", @@ -2794,6 +2964,14 @@ "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==" }, + "node_modules/@types/tunnel": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@types/tunnel/-/tunnel-0.0.3.tgz", + "integrity": "sha512-sOUTGn6h1SfQ+gbgqC364jLFBw2lnFqkgF3q0WovEHRLMrVD1sd5aufqi/aJObLekJO+Aq5z646U4Oxy6shXMA==", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/web-bluetooth": { "version": "0.0.20", "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz", @@ -5643,6 +5821,14 @@ "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", "dev": true }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/execa": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", @@ -9667,6 +9853,11 @@ "node": ">=14.0.0" } }, + "node_modules/sax": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.3.0.tgz", + "integrity": "sha512-0s+oAmw9zLl1V1cS9BtZN7JAd0cW5e0QH4W3LWEK6a4LaLEA2OTpGYWDY+6XasBLtz6wkm3u1xRw95mRuJ59WA==" + }, "node_modules/search-insights": { "version": "2.13.0", "resolved": "https://registry.npmjs.org/search-insights/-/search-insights-2.13.0.tgz", @@ -10907,6 +11098,14 @@ "@esbuild/win32-x64": "0.19.12" } }, + "node_modules/tunnel": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", + "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", + "engines": { + "node": ">=0.6.11 <=0.7.0 || >=0.7.3" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -12185,6 +12384,26 @@ "node": ">=12" } }, + "node_modules/xml2js": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz", + "integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "engines": { + "node": ">=4.0" + } + }, "node_modules/xmlhttprequest-ssl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz", From 1567623cae7958bf0a45ce41b6a25ec264317418 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Momme=20J=C3=BCrgensen?= Date: Thu, 18 Apr 2024 23:08:51 +0200 Subject: [PATCH 08/14] restructure file config add environment vars for config add helm chart add azure folder support --- api/config/custom-environment-variables.json | 12 +++++++++ api/config/default.json | 19 +++++++------ api/src/azureStorage.ts | 10 ++++--- api/src/config.ts | 19 +++++++------ api/src/middleware/downloadFileLocal.ts | 2 +- api/src/middleware/uploadFileLocal.ts | 2 +- api/src/services/file/fileCreate.ts | 9 ++++--- api/src/services/file/fileGetUrl.ts | 7 ++--- .../templates/deployment-app.yaml | 27 +++++++++++++++++++ chart/brahmsee-digital/templates/secrets.yaml | 13 +++++++++ chart/brahmsee-digital/values.yaml | 9 +++++++ frontend/src/components/DownloadLink.vue | 2 ++ 12 files changed, 103 insertions(+), 28 deletions(-) diff --git a/api/config/custom-environment-variables.json b/api/config/custom-environment-variables.json index 4f21cd4b..e9dd733c 100644 --- a/api/config/custom-environment-variables.json +++ b/api/config/custom-environment-variables.json @@ -22,5 +22,17 @@ "meilisearch": { "host": "MEILISEARCH_HOST", "apiKey": "MEILISEARCH_KEY" + }, + "fileDefaultProvider": "FILE_DEFAULT_PROVIDER", + "fileProviders": { + "LOCAL": { + "path":"FILE_PROVIDER_LOCAL_PATH" + }, + "AZURE": { + "account": "FILE_PROVIDER_AZURE_ACCOUNT", + "accountKey": "FILE_PROVIDER_AZURE_ACCOUNT_KEY", + "container": "FILE_PROVIDER_AZURE_CONTAINER", + "folder": "FILE_PROVIDER_AZURE_FOLDER" + } } } diff --git a/api/config/default.json b/api/config/default.json index 781de3e5..1da8b98c 100644 --- a/api/config/default.json +++ b/api/config/default.json @@ -31,13 +31,16 @@ "host": "meilisearch:7700", "apiKey": "xbAPupQKhkKvF176vE2JxAdPGpmWVu251Hldn6K4Z6Y" }, - "defaultFileProvider": "AZURE", - "fileLOCAL": { - "path":"../uploads" - }, - "fileAZURE": { - "account": "", - "accountKey": "", - "container": "" + "fileDefaultProvider": "LOCAL", + "fileProviders": { + "LOCAL": { + "path":"../uploads" + }, + "AZURE": { + "account": "", + "accountKey": "", + "container": "", + "folder": "" + } } } diff --git a/api/src/azureStorage.ts b/api/src/azureStorage.ts index 5e2dc36f..719f5016 100644 --- a/api/src/azureStorage.ts +++ b/api/src/azureStorage.ts @@ -3,18 +3,20 @@ import { BlobServiceClient, StorageSharedKeyCredential } from '@azure/storage-bl import config from './config' const isAzureConfigured = - config.fileAZURE.account !== '' && config.fileAZURE.accountKey !== '' && config.fileAZURE.container !== '' + config.fileProviders.AZURE.account !== '' && + config.fileProviders.AZURE.accountKey !== '' && + config.fileProviders.AZURE.container !== '' async function init() { if (!isAzureConfigured) return null - const account = config.fileAZURE.account - const accountKey = config.fileAZURE.accountKey + const account = config.fileProviders.AZURE.account + const accountKey = config.fileProviders.AZURE.accountKey const sharedKeyCredential = new StorageSharedKeyCredential(account, accountKey) const blobServiceClient = new BlobServiceClient(`https://${account}.blob.core.windows.net`, sharedKeyCredential) // Check if container exists, if not create it - const containerClient = blobServiceClient.getContainerClient(config.fileAZURE.container) + const containerClient = blobServiceClient.getContainerClient(config.fileProviders.AZURE.container) if (!(await containerClient.exists())) await containerClient.create() return blobServiceClient } diff --git a/api/src/config.ts b/api/src/config.ts index f05ba668..a5a19529 100644 --- a/api/src/config.ts +++ b/api/src/config.ts @@ -44,14 +44,17 @@ export const configSchema = z.strictObject({ host: z.string(), apiKey: z.string(), }), - defaultFileProvider: z.nativeEnum(FileProvider), - fileLOCAL: z.strictObject({ - path: z.string(), - }), - fileAZURE: z.strictObject({ - account: z.string(), - accountKey: z.string(), - container: z.string(), + fileDefaultProvider: z.nativeEnum(FileProvider), + fileProviders: z.strictObject({ + LOCAL: z.strictObject({ + path: z.string(), + }), + AZURE: z.strictObject({ + account: z.string(), + accountKey: z.string(), + container: z.string(), + folder: z.string(), + }), }), }) diff --git a/api/src/middleware/downloadFileLocal.ts b/api/src/middleware/downloadFileLocal.ts index 882f9b20..4cb6f57e 100644 --- a/api/src/middleware/downloadFileLocal.ts +++ b/api/src/middleware/downloadFileLocal.ts @@ -28,7 +28,7 @@ export const downloadFileLocal: Middleware = async function (ctx, next) { return } - const uploadDir = path.join(process.cwd(), config.fileLOCAL.path) + const uploadDir = path.join(process.cwd(), config.fileProviders.LOCAL.path) const mimetype = file.mimetype ?? 'application/octet-stream' const filename = file.filename ?? `${file.id}.${mime.extension(mimetype)}` ctx.set('Content-disposition', `attachment; filename=${filename}`) diff --git a/api/src/middleware/uploadFileLocal.ts b/api/src/middleware/uploadFileLocal.ts index f08ce80f..b01ae6d0 100644 --- a/api/src/middleware/uploadFileLocal.ts +++ b/api/src/middleware/uploadFileLocal.ts @@ -34,7 +34,7 @@ export const uploadFileLocal: Middleware = async function (ctx, next) { return } - const uploadDir = path.join(process.cwd(), config.fileLOCAL.path) + const uploadDir = path.join(process.cwd(), config.fileProviders.LOCAL.path) try { await checkLocalUploadFolder(uploadDir) } catch (e) { diff --git a/api/src/services/file/fileCreate.ts b/api/src/services/file/fileCreate.ts index 49a700a9..06fcb5a4 100644 --- a/api/src/services/file/fileCreate.ts +++ b/api/src/services/file/fileCreate.ts @@ -17,8 +17,11 @@ export const fileCreateProcedure = defineProcedure({ mimetype: z.string(), }), async handler(options) { - const provider = config.defaultFileProvider - const key: string = randomUUID() + const provider = config.fileDefaultProvider + let key: string = randomUUID() + if (provider === 'AZURE') { + key = `${config.fileProviders.AZURE.folder}/${key}` + } const file = await prisma.file.create({ data: { @@ -35,7 +38,7 @@ export const fileCreateProcedure = defineProcedure({ let azureUploadUrl: string | null = null if (file.provider === 'AZURE' && azureStorage !== null) { - const containerClient = azureStorage.getContainerClient(config.fileAZURE.container) + const containerClient = azureStorage.getContainerClient(config.fileProviders.AZURE.container) const blockBlobClient = containerClient.getBlockBlobClient(key) const permissions = BlobSASPermissions.from({ read: true, write: true, add: true, create: true }) azureUploadUrl = await blockBlobClient.generateSasUrl({ diff --git a/api/src/services/file/fileGetUrl.ts b/api/src/services/file/fileGetUrl.ts index 76d232d2..d723c46f 100644 --- a/api/src/services/file/fileGetUrl.ts +++ b/api/src/services/file/fileGetUrl.ts @@ -28,12 +28,13 @@ export const fileGetUrlActionProcedure = defineProcedure({ key: true, }, }) - if (!file.uploaded) throw new Error('File is not uploaded') - if (file.provider === 'LOCAL') + if (file.provider === 'LOCAL') { + if (!file.uploaded) throw new Error('File is not uploaded') return new URL(`/api/download/file/${file.provider}/${file.id}`, config.clientUrl).href + } if (file.provider === 'AZURE' && azureStorage !== null) { - const containerClient = azureStorage.getContainerClient(config.fileAZURE.container) + const containerClient = azureStorage.getContainerClient(config.fileProviders.AZURE.container) const blockBlobClient = containerClient.getBlockBlobClient(file.key) return await blockBlobClient.generateSasUrl({ startsOn: dayjs().subtract(5, 'minute').toDate(), diff --git a/chart/brahmsee-digital/templates/deployment-app.yaml b/chart/brahmsee-digital/templates/deployment-app.yaml index 4835a33b..3f8e709a 100644 --- a/chart/brahmsee-digital/templates/deployment-app.yaml +++ b/chart/brahmsee-digital/templates/deployment-app.yaml @@ -65,6 +65,33 @@ spec: name: meilisearch key: key + + # FILE PROVIDER + - name: FILE_DEFAULT_PROVIDER + value: "{{ .Values.app.fileDefaultProvider }}" + - name: FILE_PROVIDER_LOCAL_PATH + value: "{{ .Values.app.fileProviders.LOCAL.path }}" + - name: FILE_PROVIDER_AZURE_ACCOUNT + valueFrom: + secretKeyRef: + name: azure + key: account + - name: FILE_PROVIDER_AZURE_ACCOUNT_KEY + valueFrom: + secretKeyRef: + name: azure + key: accountKey + - name: FILE_PROVIDER_AZURE_CONTAINER + valueFrom: + secretKeyRef: + name: azure + key: container + - name: FILE_PROVIDER_AZURE_FOLDER + valueFrom: + secretKeyRef: + name: azure + key: folder + # readinessProbe: # httpGet: # path: /ready diff --git a/chart/brahmsee-digital/templates/secrets.yaml b/chart/brahmsee-digital/templates/secrets.yaml index 4c3b14bc..380d7c1e 100644 --- a/chart/brahmsee-digital/templates/secrets.yaml +++ b/chart/brahmsee-digital/templates/secrets.yaml @@ -39,3 +39,16 @@ metadata: stringData: key: {{ .Values.app.meilisearch.key }} type: Opaque +--- +apiVersion: v1 +kind: Secret +metadata: + name: azure + labels: + {{- include "codeanker.label" . | indent 4 }} +stringData: + account: {{ .Values.app.fileProviders.AZURE.account }} + accountKey: {{ .Values.app.fileProviders.AZURE.accountKey }} + container: {{ .Values.app.fileProviders.AZURE.container }} + folder: {{ .Values.app.fileProviders.AZURE.folder }} +type: Opaque diff --git a/chart/brahmsee-digital/values.yaml b/chart/brahmsee-digital/values.yaml index a95bff63..1b610791 100644 --- a/chart/brahmsee-digital/values.yaml +++ b/chart/brahmsee-digital/values.yaml @@ -16,6 +16,15 @@ app: jwt_secret: "" meilisearch: key: 'defaultKey' + fileDefaultProvider: 'LOCAL' + fileProviders: + LOCAL: + path: /tmp + AZURE: + account: "", + accountKey: "", + container: "" + folder: "" postgres: enabled: true diff --git a/frontend/src/components/DownloadLink.vue b/frontend/src/components/DownloadLink.vue index 0551baf9..8174e022 100644 --- a/frontend/src/components/DownloadLink.vue +++ b/frontend/src/components/DownloadLink.vue @@ -25,6 +25,8 @@ const { state: link } = useAsyncState(async () => { v-else :href="link" download + target="_blank" + rel="noopener noreferrer" class="text-primary-500 flex" > {{ label ?? 'Herunterladen' }} From 9c9236cfefade88a3699f9246e781d6a89f02f77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Momme=20J=C3=BCrgensen?= Date: Thu, 18 Apr 2024 23:11:52 +0200 Subject: [PATCH 09/14] squash file migrations --- .../migration.sql | 18 -------------- .../migrations/20240417213016_/migration.sql | 2 -- .../migrations/20240418192004_/migration.sql | 2 -- .../migration.sql | 24 +++++++++++++++---- 4 files changed, 19 insertions(+), 27 deletions(-) delete mode 100644 api/prisma/migrations/20240417185339_add_file_model/migration.sql delete mode 100644 api/prisma/migrations/20240417213016_/migration.sql delete mode 100644 api/prisma/migrations/20240418192004_/migration.sql rename api/prisma/migrations/{20240417192339_ => 20240418211129_add_file_model}/migration.sql (64%) diff --git a/api/prisma/migrations/20240417185339_add_file_model/migration.sql b/api/prisma/migrations/20240417185339_add_file_model/migration.sql deleted file mode 100644 index 5ccdfccb..00000000 --- a/api/prisma/migrations/20240417185339_add_file_model/migration.sql +++ /dev/null @@ -1,18 +0,0 @@ --- CreateEnum -CREATE TYPE "FileProvider" AS ENUM ('LOCAL'); - --- CreateTable -CREATE TABLE "File" ( - "id" SERIAL NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "uploaded" BOOLEAN NOT NULL DEFAULT false, - "uploadedAt" TIMESTAMP(3), - "provider" "FileProvider" NOT NULL, - "key" TEXT NOT NULL, - "mimetype" TEXT, - - CONSTRAINT "File_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE UNIQUE INDEX "File_key_key" ON "File"("key"); diff --git a/api/prisma/migrations/20240417213016_/migration.sql b/api/prisma/migrations/20240417213016_/migration.sql deleted file mode 100644 index d76b099a..00000000 --- a/api/prisma/migrations/20240417213016_/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- AlterTable -ALTER TABLE "File" ADD COLUMN "filename" TEXT; diff --git a/api/prisma/migrations/20240418192004_/migration.sql b/api/prisma/migrations/20240418192004_/migration.sql deleted file mode 100644 index 16bc6d86..00000000 --- a/api/prisma/migrations/20240418192004_/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- AlterEnum -ALTER TYPE "FileProvider" ADD VALUE 'AZURE'; diff --git a/api/prisma/migrations/20240417192339_/migration.sql b/api/prisma/migrations/20240418211129_add_file_model/migration.sql similarity index 64% rename from api/prisma/migrations/20240417192339_/migration.sql rename to api/prisma/migrations/20240418211129_add_file_model/migration.sql index 5adc618c..a1de632e 100644 --- a/api/prisma/migrations/20240417192339_/migration.sql +++ b/api/prisma/migrations/20240418211129_add_file_model/migration.sql @@ -1,9 +1,6 @@ -/* - Warnings: +-- CreateEnum +CREATE TYPE "FileProvider" AS ENUM ('LOCAL', 'AZURE'); - - A unique constraint covering the columns `[id]` on the table `File` will be added. If there are existing duplicate values, this will fail. - -*/ -- CreateTable CREATE TABLE "UnterveranstaltungDocument" ( "id" SERIAL NOT NULL, @@ -15,12 +12,29 @@ CREATE TABLE "UnterveranstaltungDocument" ( CONSTRAINT "UnterveranstaltungDocument_pkey" PRIMARY KEY ("id") ); +-- CreateTable +CREATE TABLE "File" ( + "id" SERIAL NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "uploaded" BOOLEAN NOT NULL DEFAULT false, + "uploadedAt" TIMESTAMP(3), + "provider" "FileProvider" NOT NULL, + "key" TEXT NOT NULL, + "filename" TEXT, + "mimetype" TEXT, + + CONSTRAINT "File_pkey" PRIMARY KEY ("id") +); + -- CreateIndex CREATE UNIQUE INDEX "UnterveranstaltungDocument_fileId_key" ON "UnterveranstaltungDocument"("fileId"); -- CreateIndex CREATE UNIQUE INDEX "File_id_key" ON "File"("id"); +-- CreateIndex +CREATE UNIQUE INDEX "File_key_key" ON "File"("key"); + -- AddForeignKey ALTER TABLE "UnterveranstaltungDocument" ADD CONSTRAINT "UnterveranstaltungDocument_unterveranstaltungId_fkey" FOREIGN KEY ("unterveranstaltungId") REFERENCES "Unterveranstaltung"("id") ON DELETE CASCADE ON UPDATE CASCADE; From fadff8133866429b4baf4544e1efb49fb60572a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Momme=20J=C3=BCrgensen?= Date: Thu, 18 Apr 2024 23:17:27 +0200 Subject: [PATCH 10/14] restrict file create endpoint --- api/src/services/file/fileCreate.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/api/src/services/file/fileCreate.ts b/api/src/services/file/fileCreate.ts index 06fcb5a4..d3080948 100644 --- a/api/src/services/file/fileCreate.ts +++ b/api/src/services/file/fileCreate.ts @@ -12,7 +12,10 @@ import { defineProcedure } from '../../types/defineProcedure' export const fileCreateProcedure = defineProcedure({ key: 'fileCreate', method: 'mutation', - protection: { type: 'public' }, // TODO: authentication + protection: { + type: 'restrictToRoleIds', + roleIds: ['ADMIN', 'GLIEDERUNG_ADMIN'], + }, inputSchema: z.strictObject({ mimetype: z.string(), }), From e9561060a3d89d1dc9c73a4989cd0dc621eddbd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Momme=20J=C3=BCrgensen?= Date: Thu, 18 Apr 2024 23:52:57 +0200 Subject: [PATCH 11/14] improve file security --- .../migration.sql | 4 ++-- api/prisma/schema.prisma | 4 ++-- api/src/middleware/downloadFileLocal.ts | 2 +- api/src/middleware/uploadFileLocal.ts | 2 +- api/src/services/file/fileGetUrl.ts | 4 ++-- .../unterveranstaltungGliederungPatch.ts | 8 +++++--- .../unterveranstaltungVerwaltungPatch.ts | 8 +++++--- frontend/src/components/DownloadLink.vue | 2 +- 8 files changed, 19 insertions(+), 15 deletions(-) rename api/prisma/migrations/{20240418211129_add_file_model => 20240418213610_add_file_and_unterveranstaltung_document_models}/migration.sql (95%) diff --git a/api/prisma/migrations/20240418211129_add_file_model/migration.sql b/api/prisma/migrations/20240418213610_add_file_and_unterveranstaltung_document_models/migration.sql similarity index 95% rename from api/prisma/migrations/20240418211129_add_file_model/migration.sql rename to api/prisma/migrations/20240418213610_add_file_and_unterveranstaltung_document_models/migration.sql index a1de632e..88155037 100644 --- a/api/prisma/migrations/20240418211129_add_file_model/migration.sql +++ b/api/prisma/migrations/20240418213610_add_file_and_unterveranstaltung_document_models/migration.sql @@ -7,14 +7,14 @@ CREATE TABLE "UnterveranstaltungDocument" ( "name" TEXT NOT NULL, "description" TEXT, "unterveranstaltungId" INTEGER NOT NULL, - "fileId" INTEGER NOT NULL, + "fileId" TEXT NOT NULL, CONSTRAINT "UnterveranstaltungDocument_pkey" PRIMARY KEY ("id") ); -- CreateTable CREATE TABLE "File" ( - "id" SERIAL NOT NULL, + "id" TEXT NOT NULL, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "uploaded" BOOLEAN NOT NULL DEFAULT false, "uploadedAt" TIMESTAMP(3), diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 53e5ab3b..24ffd709 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -305,7 +305,7 @@ model UnterveranstaltungDocument { unterveranstaltungId Int unterveranstaltung Unterveranstaltung? @relation(fields: [unterveranstaltungId], references: [id], onDelete: Cascade) file File @relation(fields: [fileId], references: [id]) - fileId Int @unique + fileId String @unique } enum ActivityType { @@ -376,7 +376,7 @@ enum FileProvider { } model File { - id Int @id @unique @default(autoincrement()) + id String @id @unique @default(uuid()) createdAt DateTime @default(now()) uploaded Boolean @default(false) uploadedAt DateTime? diff --git a/api/src/middleware/downloadFileLocal.ts b/api/src/middleware/downloadFileLocal.ts index 4cb6f57e..8e428efa 100644 --- a/api/src/middleware/downloadFileLocal.ts +++ b/api/src/middleware/downloadFileLocal.ts @@ -10,7 +10,7 @@ import prisma from '../prisma' // eslint-disable-next-line @typescript-eslint/no-unused-vars export const downloadFileLocal: Middleware = async function (ctx, next) { // TODO: add authentication - const fileId = parseInt(ctx.params.id) + const fileId = ctx.params.id const file = await prisma.file.findFirst({ where: { id: fileId, diff --git a/api/src/middleware/uploadFileLocal.ts b/api/src/middleware/uploadFileLocal.ts index b01ae6d0..ce77783a 100644 --- a/api/src/middleware/uploadFileLocal.ts +++ b/api/src/middleware/uploadFileLocal.ts @@ -10,7 +10,7 @@ import prisma from '../prisma' export const uploadFileLocal: Middleware = async function (ctx, next) { // TODO: add authentication - const fileId = parseInt(ctx.params.id) + const fileId = ctx.params.id const file = await prisma.file.findFirst({ where: { id: fileId, diff --git a/api/src/services/file/fileGetUrl.ts b/api/src/services/file/fileGetUrl.ts index d723c46f..0e7c5dc4 100644 --- a/api/src/services/file/fileGetUrl.ts +++ b/api/src/services/file/fileGetUrl.ts @@ -12,9 +12,9 @@ const downloadUrlLifespan = 60 * 60 // 1 hour export const fileGetUrlActionProcedure = defineProcedure({ key: 'fileGetUrl', method: 'query', - protection: { type: 'public' }, // TODO: authentication + protection: { type: 'public' }, inputSchema: z.strictObject({ - id: z.number().int(), + id: z.string().uuid(), }), async handler(options) { const file = await prisma.file.findUniqueOrThrow({ diff --git a/api/src/services/unterveranstaltung/unterveranstaltungGliederungPatch.ts b/api/src/services/unterveranstaltung/unterveranstaltungGliederungPatch.ts index 831934ea..5b9ec845 100644 --- a/api/src/services/unterveranstaltung/unterveranstaltungGliederungPatch.ts +++ b/api/src/services/unterveranstaltung/unterveranstaltungGliederungPatch.ts @@ -1,3 +1,5 @@ +import type { UUID } from 'crypto' + import z from 'zod' import prisma from '../../prisma' @@ -21,7 +23,7 @@ export const unterveranstaltungGliederungPatchProcedure = defineProcedure({ .array( z.strictObject({ name: z.string(), - fileId: z.number().int(), + fileId: z.string().uuid(), }) ) .optional(), @@ -34,13 +36,13 @@ export const unterveranstaltungGliederungPatchProcedure = defineProcedure({ // Documents create, update, delete const documents: { - createMany?: { data: { name: string; fileId: number }[] } + createMany?: { data: { name: string; fileId: UUID }[] } updateMany?: { where: { id: number }; data: { name: string } }[] deleteMany?: { id: number }[] } = {} if (options.input.data.addDocuments) { documents.createMany = { - data: options.input.data.addDocuments, + data: options.input.data.addDocuments.map((doc) => ({ ...doc, fileId: doc.fileId as UUID })), } } if (options.input.data.updateDocuments) { diff --git a/api/src/services/unterveranstaltung/unterveranstaltungVerwaltungPatch.ts b/api/src/services/unterveranstaltung/unterveranstaltungVerwaltungPatch.ts index 3d66196e..9a92812e 100644 --- a/api/src/services/unterveranstaltung/unterveranstaltungVerwaltungPatch.ts +++ b/api/src/services/unterveranstaltung/unterveranstaltungVerwaltungPatch.ts @@ -1,3 +1,5 @@ +import type { UUID } from 'crypto' + import z from 'zod' import prisma from '../../prisma' @@ -20,7 +22,7 @@ export const unterveranstaltungVerwaltungPatchProcedure = defineProcedure({ .array( z.strictObject({ name: z.string(), - fileId: z.number().int(), + fileId: z.string().uuid(), }) ) .optional(), @@ -31,13 +33,13 @@ export const unterveranstaltungVerwaltungPatchProcedure = defineProcedure({ async handler(options) { // Documents create, update, delete const documents: { - createMany?: { data: { name: string; fileId: number }[] } + createMany?: { data: { name: string; fileId: UUID }[] } updateMany?: { where: { id: number }; data: { name: string } }[] deleteMany?: { id: number }[] } = {} if (options.input.data.addDocuments) { documents.createMany = { - data: options.input.data.addDocuments, + data: options.input.data.addDocuments.map((doc) => ({ ...doc, fileId: doc.fileId as UUID })), } } if (options.input.data.updateDocuments) { diff --git a/frontend/src/components/DownloadLink.vue b/frontend/src/components/DownloadLink.vue index 8174e022..cad5230d 100644 --- a/frontend/src/components/DownloadLink.vue +++ b/frontend/src/components/DownloadLink.vue @@ -6,7 +6,7 @@ import { apiClient } from '@/api' import Loading from '@/components/UIComponents/Loading.vue' const props = defineProps<{ - fileId: number + fileId: string label?: string }>() From 4b167ba21c9830d1b8df38c40a6cf60102c8bcca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Momme=20J=C3=BCrgensen?= Date: Tue, 23 Apr 2024 22:01:22 +0200 Subject: [PATCH 12/14] change requests --- api/src/middleware/downloadFileLocal.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/api/src/middleware/downloadFileLocal.ts b/api/src/middleware/downloadFileLocal.ts index 8e428efa..390c01fe 100644 --- a/api/src/middleware/downloadFileLocal.ts +++ b/api/src/middleware/downloadFileLocal.ts @@ -17,14 +17,12 @@ export const downloadFileLocal: Middleware = async function (ctx, next) { }, }) if (file === null) { - ctx.response.status = 400 - ctx.response.body = { error: `File with id '${fileId}' not found` } + ctx.response.status = 404 return } if (file.provider !== 'LOCAL') { - ctx.response.status = 400 - ctx.response.body = { error: `File provider is '${file.provider}'. This endpoint is for LOCAL` } + ctx.response.status = 404 return } From f6adb633660f9f0fedf6ee8a85e775666ddcbef6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Momme=20J=C3=BCrgensen?= Date: Mon, 18 Nov 2024 17:40:59 +0100 Subject: [PATCH 13/14] fix migration --- .../migration.sql | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) rename api/prisma/migrations/{20240418213610_add_file_and_unterveranstaltung_document_models => 20241118163314_init_file}/migration.sql (100%) diff --git a/api/prisma/migrations/20240418213610_add_file_and_unterveranstaltung_document_models/migration.sql b/api/prisma/migrations/20241118163314_init_file/migration.sql similarity index 100% rename from api/prisma/migrations/20240418213610_add_file_and_unterveranstaltung_document_models/migration.sql rename to api/prisma/migrations/20241118163314_init_file/migration.sql index 88155037..33e407ff 100644 --- a/api/prisma/migrations/20240418213610_add_file_and_unterveranstaltung_document_models/migration.sql +++ b/api/prisma/migrations/20241118163314_init_file/migration.sql @@ -1,17 +1,6 @@ -- CreateEnum CREATE TYPE "FileProvider" AS ENUM ('LOCAL', 'AZURE'); --- CreateTable -CREATE TABLE "UnterveranstaltungDocument" ( - "id" SERIAL NOT NULL, - "name" TEXT NOT NULL, - "description" TEXT, - "unterveranstaltungId" INTEGER NOT NULL, - "fileId" TEXT NOT NULL, - - CONSTRAINT "UnterveranstaltungDocument_pkey" PRIMARY KEY ("id") -); - -- CreateTable CREATE TABLE "File" ( "id" TEXT NOT NULL, @@ -26,8 +15,16 @@ CREATE TABLE "File" ( CONSTRAINT "File_pkey" PRIMARY KEY ("id") ); --- CreateIndex -CREATE UNIQUE INDEX "UnterveranstaltungDocument_fileId_key" ON "UnterveranstaltungDocument"("fileId"); +-- CreateTable +CREATE TABLE "UnterveranstaltungDocument" ( + "id" SERIAL NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "unterveranstaltungId" INTEGER NOT NULL, + "fileId" TEXT NOT NULL, + + CONSTRAINT "UnterveranstaltungDocument_pkey" PRIMARY KEY ("id") +); -- CreateIndex CREATE UNIQUE INDEX "File_id_key" ON "File"("id"); @@ -35,6 +32,9 @@ CREATE UNIQUE INDEX "File_id_key" ON "File"("id"); -- CreateIndex CREATE UNIQUE INDEX "File_key_key" ON "File"("key"); +-- CreateIndex +CREATE UNIQUE INDEX "UnterveranstaltungDocument_fileId_key" ON "UnterveranstaltungDocument"("fileId"); + -- AddForeignKey ALTER TABLE "UnterveranstaltungDocument" ADD CONSTRAINT "UnterveranstaltungDocument_unterveranstaltungId_fkey" FOREIGN KEY ("unterveranstaltungId") REFERENCES "Unterveranstaltung"("id") ON DELETE CASCADE ON UPDATE CASCADE; From f4fc6088dccadd487c8c4df068a3983eef0ab360 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Momme=20J=C3=BCrgensen?= Date: Mon, 18 Nov 2024 17:55:55 +0100 Subject: [PATCH 14/14] remove todo + remove file-test --- api/src/middleware/downloadFileLocal.ts | 1 - api/src/middleware/uploadFileLocal.ts | 2 - frontend/src/helpers/handleUpload.ts | 3 -- .../views/Verwaltung/FileTest/FileList.vue | 42 ------------------- .../src/views/Verwaltung/FileTest/routes.ts | 32 -------------- frontend/src/views/Verwaltung/routes.ts | 2 - 6 files changed, 82 deletions(-) delete mode 100644 frontend/src/views/Verwaltung/FileTest/FileList.vue delete mode 100644 frontend/src/views/Verwaltung/FileTest/routes.ts diff --git a/api/src/middleware/downloadFileLocal.ts b/api/src/middleware/downloadFileLocal.ts index 390c01fe..e4f43a61 100644 --- a/api/src/middleware/downloadFileLocal.ts +++ b/api/src/middleware/downloadFileLocal.ts @@ -9,7 +9,6 @@ import prisma from '../prisma' // eslint-disable-next-line @typescript-eslint/no-unused-vars export const downloadFileLocal: Middleware = async function (ctx, next) { - // TODO: add authentication const fileId = ctx.params.id const file = await prisma.file.findFirst({ where: { diff --git a/api/src/middleware/uploadFileLocal.ts b/api/src/middleware/uploadFileLocal.ts index ce77783a..41c23cdb 100644 --- a/api/src/middleware/uploadFileLocal.ts +++ b/api/src/middleware/uploadFileLocal.ts @@ -8,8 +8,6 @@ import prisma from '../prisma' // eslint-disable-next-line @typescript-eslint/no-unused-vars export const uploadFileLocal: Middleware = async function (ctx, next) { - // TODO: add authentication - const fileId = ctx.params.id const file = await prisma.file.findFirst({ where: { diff --git a/frontend/src/helpers/handleUpload.ts b/frontend/src/helpers/handleUpload.ts index c93045fb..13373a79 100644 --- a/frontend/src/helpers/handleUpload.ts +++ b/frontend/src/helpers/handleUpload.ts @@ -15,9 +15,6 @@ export const handleUpload = async (file: File) => { if (dbFile.provider === 'LOCAL') { const response = await fetch(`/api/upload/file/LOCAL/${dbFile.id}`, { method: 'POST', - headers: { - // Authorization: 'Bearer ' + this.token, // TODO: authentication - }, body: formData, }) if (response.status != 201) throw new Error(`Failed to upload file: ${await response.text()}`) diff --git a/frontend/src/views/Verwaltung/FileTest/FileList.vue b/frontend/src/views/Verwaltung/FileTest/FileList.vue deleted file mode 100644 index ca5c6967..00000000 --- a/frontend/src/views/Verwaltung/FileTest/FileList.vue +++ /dev/null @@ -1,42 +0,0 @@ - - - - diff --git a/frontend/src/views/Verwaltung/FileTest/routes.ts b/frontend/src/views/Verwaltung/FileTest/routes.ts deleted file mode 100644 index 32dc3a82..00000000 --- a/frontend/src/views/Verwaltung/FileTest/routes.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { Route } from '@/router' - -const fileRoutes: Route[] = [ - { - name: 'Verwaltung File', - path: 'file', - redirect: { name: 'Verwaltung Alle File' }, - meta: { - breadcrumbs: [ - { - text: 'File', - }, - ], - }, - children: [ - { - name: 'Verwaltung Alle File', - path: 'liste', - component: () => import('./FileList.vue'), - meta: { - breadcrumbs: [ - { - text: 'Übersicht', - }, - ], - }, - }, - ], - }, -] - -export default fileRoutes diff --git a/frontend/src/views/Verwaltung/routes.ts b/frontend/src/views/Verwaltung/routes.ts index 5e2154ef..747bf38f 100644 --- a/frontend/src/views/Verwaltung/routes.ts +++ b/frontend/src/views/Verwaltung/routes.ts @@ -1,5 +1,4 @@ import routesAccount from './Accounts/routes' -import fileRoutes from './FileTest/routes' import gliederungRoutes from './Gliederungen/routes' import orteRoutes from './Orte/routes' import routesPerson from './Persons/routes' @@ -25,7 +24,6 @@ const routesVerwaltung: Route[] = [ ...veranstaltungRoutes, ...orteRoutes, ...routesAccount, - ...fileRoutes, ], }, ]