From 21d1baf252aeb732d495d5f1328ef5ade23078e4 Mon Sep 17 00:00:00 2001 From: Barbara Krautgartner Date: Thu, 2 Oct 2025 14:34:04 +0000 Subject: [PATCH 1/4] feat: image upload mostly from https://github.com/acdh-oeaw/template-app-next/tree/variant/with-image-upload - add image selector component - use list box for image grid - presign image urls - use component in country dashboard view (admin) --- .../admin/country-dashboard-content.tsx | 3 +- components/ui/blocks/image-selector.tsx | 168 +++++++++++++ components/ui/blocks/upload-image-form.tsx | 41 ++++ config/env.config.js | 18 ++ config/images.config.ts | 3 + lib/actions/create-image-action.ts | 96 ++++++++ lib/actions/images.ts | 23 ++ lib/server/errors.ts | 7 + lib/server/images/create-client.ts | 67 ++++++ lib/server/images/create-minio-client.ts | 14 ++ lib/server/images/generate-object-name.ts | 8 + .../images/generate-signed-image-url.ts | 18 ++ lib/server/images/get-image-dimensions.ts | 77 ++++++ lib/server/rate-limit/global-rate-limit.ts | 41 ++++ lib/server/rate-limit/rate-limiter.ts | 181 ++++++++++++++ messages/en.json | 6 + package.json | 6 + pnpm-lock.yaml | 227 ++++++++++++++++++ 18 files changed, 1003 insertions(+), 1 deletion(-) create mode 100644 components/ui/blocks/image-selector.tsx create mode 100644 components/ui/blocks/upload-image-form.tsx create mode 100644 config/images.config.ts create mode 100644 lib/actions/create-image-action.ts create mode 100644 lib/actions/images.ts create mode 100644 lib/server/errors.ts create mode 100644 lib/server/images/create-client.ts create mode 100644 lib/server/images/create-minio-client.ts create mode 100644 lib/server/images/generate-object-name.ts create mode 100644 lib/server/images/generate-signed-image-url.ts create mode 100644 lib/server/images/get-image-dimensions.ts create mode 100644 lib/server/rate-limit/global-rate-limit.ts create mode 100644 lib/server/rate-limit/rate-limiter.ts diff --git a/components/admin/country-dashboard-content.tsx b/components/admin/country-dashboard-content.tsx index bf68088f..e9e93362 100644 --- a/components/admin/country-dashboard-content.tsx +++ b/components/admin/country-dashboard-content.tsx @@ -18,6 +18,7 @@ import { AdminServicesTableContent } from "@/components/admin/services-table-con import { AdminSoftwareTableContent } from "@/components/admin/software-table-content"; import { SubmitButton } from "@/components/submit-button"; import { DateInputField } from "@/components/ui/blocks/date-input-field"; +import { ImageSelector } from "@/components/ui/blocks/image-selector"; import { NumberInputField } from "@/components/ui/blocks/number-input-field"; import { SelectField, SelectItem } from "@/components/ui/blocks/select-field"; import { TextInputField } from "@/components/ui/blocks/text-input-field"; @@ -251,7 +252,7 @@ function CountryEditForm(props: CountryEditFormProps) { })} - + + + {imageUrl ? ( +
+ { + // eslint-disable-next-line @next/next/no-img-element + + } +
+ ) : undefined} + + + + + select logo + + + + + ); +} + +interface ImagesDialogProps { + handleUpdate: (value: string) => void; +} + +export function ImageDialog(props: ImagesDialogProps): ReactNode { + const { handleUpdate } = props; + + const [selectedImage, setSelectedImage] = useState(null); + + const imagesList = useAsyncList<{ objectName: string } & { url: string }>({ + async load(_signal) { + const data = await getImageUrls(); + return { items: data }; + }, + getKey: (item) => { + return item.objectName; + }, + }); + + const onSelectionChange = (keys: Selection) => { + const image = Array.from(keys)[0] ?? null; + if (image === "all") { + setSelectedImage(null); + } else { + setSelectedImage(image); + } + }; + + const handleUploadSuccess = useCallback(() => { + imagesList.reload(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + + + + {({ close }) => { + const update = () => { + if (selectedImage) { + handleUpdate(selectedImage.toString()); + } + close(); + }; + + return ( + + + Choose Image + + + + + Cancel + + + + ); + }} + + + + ); +} + +interface ImageGridProps { + onSelectionChange: (key: Selection) => void; + imagesList: AsyncListData<{ objectName: string; url: string }>; +} + +export function ImageGrid(props: ImageGridProps): ReactNode { + const { imagesList, onSelectionChange } = props; + + return ( + + {(item) => { + const { objectName, url } = item; + return ( + + {({ isSelected }) => { + return ( +
+ {/* FIXME: add a custom loader for `next/image` if possible, otherwise create our own wrapper which generates a `srcset`. */} + {/* eslint-disable-next-line @next/next/no-img-element */} + +
{objectName}
+
+ ); + }} +
+ ); + }} +
+ ); +} diff --git a/components/ui/blocks/upload-image-form.tsx b/components/ui/blocks/upload-image-form.tsx new file mode 100644 index 00000000..c11c993e --- /dev/null +++ b/components/ui/blocks/upload-image-form.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { type ReactNode, useActionState, useEffect } from "react"; + +import { SubmitButton } from "@/components/submit-button"; +import { Form } from "@/components/ui/form"; +import { createImageAction } from "@/lib/actions/create-image-action"; + +interface UploadImageFormProps { + onUploadSuccess: () => void; +} + +export function UploadImageForm(props: UploadImageFormProps): ReactNode { + const { onUploadSuccess } = props; + const [formState, action] = useActionState(createImageAction, undefined); + + useEffect(() => { + if (formState?.status === "success") { + onUploadSuccess(); + } + }, [formState?.status, onUploadSuccess]); + + return ( +
+ {/* */} + + + +
+ Upload image +
+ + ); +} diff --git a/config/env.config.js b/config/env.config.js index b6bf0333..1003aeb2 100644 --- a/config/env.config.js +++ b/config/env.config.js @@ -39,6 +39,15 @@ export const env = createEnv({ KEYSTATIC_GITHUB_CLIENT_SECRET: z.string().min(1).optional(), KEYSTATIC_SECRET: z.string().min(1).optional(), NEXT_RUNTIME: z.enum(["edge", "nodejs"]).optional(), + IMGPROXY_BASE_URL: z.string().url(), + IMGPROXY_KEY: z.string().min(1), + IMGPROXY_SALT: z.string().min(1), + S3_ACCESS_KEY: z.string().min(1), + S3_BUCKET: z.string().min(1), + S3_HOST: z.string().min(1), + S3_PORT: z.string().transform(Number).pipe(z.number().int().min(1)), + S3_PROTOCOL: z.enum(["http", "https"]).optional().default("https"), + S3_SECRET_KEY: z.string().min(1), SENTRY_AUTH_TOKEN: z.string().min(1).optional(), SSHOC_MARKETPLACE_API_BASE_URL: z.string().url(), SSHOC_MARKETPLACE_BASE_URL: z.string().url(), @@ -87,6 +96,9 @@ export const env = createEnv({ EMAIL_SMTP_PORT: process.env.EMAIL_SMTP_PORT, EMAIL_SMTP_SERVER: process.env.EMAIL_SMTP_SERVER, EMAIL_USER_NAME: process.env.EMAIL_USER_NAME, + IMGPROXY_BASE_URL: process.env.IMGPROXY_BASE_URL, + IMGPROXY_KEY: process.env.IMGPROXY_KEY, + IMGPROXY_SALT: process.env.IMGPROXY_SALT, KEYSTATIC_GITHUB_CLIENT_ID: process.env.KEYSTATIC_GITHUB_CLIENT_ID, KEYSTATIC_GITHUB_CLIENT_SECRET: process.env.KEYSTATIC_GITHUB_CLIENT_SECRET, KEYSTATIC_SECRET: process.env.KEYSTATIC_SECRET, @@ -105,6 +117,12 @@ export const env = createEnv({ NEXT_PUBLIC_SENTRY_ORG: process.env.NEXT_PUBLIC_SENTRY_ORG, NEXT_PUBLIC_SENTRY_PROJECT: process.env.NEXT_PUBLIC_SENTRY_PROJECT, NEXT_RUNTIME: process.env.NEXT_RUNTIME, + S3_ACCESS_KEY: process.env.S3_ACCESS_KEY, + S3_BUCKET: process.env.S3_BUCKET, + S3_HOST: process.env.S3_HOST, + S3_PORT: process.env.S3_PORT, + S3_PROTOCOL: process.env.S3_PROTOCOL, + S3_SECRET_KEY: process.env.S3_SECRET_KEY, SENTRY_AUTH_TOKEN: process.env.SENTRY_AUTH_TOKEN, SSHOC_MARKETPLACE_API_BASE_URL: process.env.SSHOC_MARKETPLACE_API_BASE_URL, SSHOC_MARKETPLACE_BASE_URL: process.env.SSHOC_MARKETPLACE_BASE_URL, diff --git a/config/images.config.ts b/config/images.config.ts new file mode 100644 index 00000000..d2dd0eb7 --- /dev/null +++ b/config/images.config.ts @@ -0,0 +1,3 @@ +export const imageMimeTypes = ["image/jpeg", "image/png"] as const; + +export const imageSizeLimit = 4 * 1024 * 1024; /** 4 MB */ diff --git a/lib/actions/create-image-action.ts b/lib/actions/create-image-action.ts new file mode 100644 index 00000000..4bdd68e9 --- /dev/null +++ b/lib/actions/create-image-action.ts @@ -0,0 +1,96 @@ +"use server"; + +import { Readable } from "node:stream"; +import type { ReadableStream } from "node:stream/web"; + +import { getFormDataValues, log } from "@acdh-oeaw/lib"; +import { getTranslations } from "next-intl/server"; +import { z } from "zod"; + +import { imageMimeTypes, imageSizeLimit } from "@/config/images.config"; +import { assertAuthenticated } from "@/lib/server/auth/assert-authenticated"; +import { RateLimitError } from "@/lib/server/errors"; +import { createClient } from "@/lib/server/images/create-client"; +import { globalPOSTRateLimit } from "@/lib/server/rate-limit/global-rate-limit"; + +const formSchema = z.object({ + file: z + .instanceof(File) + .refine((file) => { + return (imageMimeTypes as ReadonlyArray).includes(file.type); + }) + .refine((file) => { + return file.size <= imageSizeLimit; + }), +}); + +type FormSchema = z.infer; + +interface FormReturnValue { + timestamp: number; +} + +interface FormErrors extends FormReturnValue, z.typeToFlattenedError { + status: "error"; +} + +interface FormSuccess extends FormReturnValue { + status: "success"; + message: string; + data: { objectName: string }; +} + +type FormState = FormErrors | FormSuccess; + +export async function createImageAction( + _previousFormState: FormState | undefined, + formData: FormData, +): Promise { + const t = await getTranslations("actions.createImage"); + + if (!(await globalPOSTRateLimit())) { + throw new RateLimitError(); + } + + await assertAuthenticated(); + + const input = getFormDataValues(formData); + const result = formSchema.safeParse(input); + + if (!result.success) { + log.error(result.error.flatten()); + + return { + status: "error" as const, + ...result.error.flatten(), + timestamp: Date.now(), + }; + } + const { file } = result.data; + + try { + const fileName = file.name; + const fileStream = Readable.fromWeb(file.stream() as ReadableStream); + const fileSize = file.size; + const metadata = { "Content-Type": file.type }; + + const client = await createClient(); + const { objectName } = await client.images.create(fileName, fileStream, fileSize, metadata); + + return { + status: "success" as const, + message: t("success"), + data: { objectName }, + timestamp: Date.now(), + }; + } catch (error) { + log.error(error); + + return { + status: "error" as const, + formErrors: [t("errors.DefaultImageCreationError")], + fieldErrors: {}, + timestamp: Date.now(), + }; + } +} diff --git a/lib/actions/images.ts b/lib/actions/images.ts new file mode 100644 index 00000000..34c6926e --- /dev/null +++ b/lib/actions/images.ts @@ -0,0 +1,23 @@ +"use server"; + +import { assertAuthenticated } from "@/lib/server/auth/assert-authenticated"; +import { createClient } from "@/lib/server/images/create-client"; + +export async function getImageUrls() { + await assertAuthenticated(); + const client = await createClient(); + const { images } = await client.images.all(); + + const imagesUpdated = await Promise.all( + images.map(async (image) => { + const { objectName } = image; + const { url } = await client.signedImageUrls.get(objectName); + return { + ...image, + url, + }; + }), + ); + + return imagesUpdated; +} diff --git a/lib/server/errors.ts b/lib/server/errors.ts new file mode 100644 index 00000000..1175f259 --- /dev/null +++ b/lib/server/errors.ts @@ -0,0 +1,7 @@ +export class ForbiddenError extends Error { + name = "Forbidden"; +} + +export class RateLimitError extends Error { + name = "RateLimit"; +} diff --git a/lib/server/images/create-client.ts b/lib/server/images/create-client.ts new file mode 100644 index 00000000..5a371df4 --- /dev/null +++ b/lib/server/images/create-client.ts @@ -0,0 +1,67 @@ +import type { Readable } from "node:stream"; + +import { assert } from "@acdh-oeaw/lib"; +import type { BucketItem, ItemBucketMetadata } from "minio"; +import { cache } from "react"; + +import { env } from "@/config/env.config"; +import { createMinioClient } from "@/lib/server/images/create-minio-client"; +import { generateObjectName } from "@/lib/server/images/generate-object-name"; +import { generateSignedImageUrl } from "@/lib/server/images/generate-signed-image-url"; + +// eslint-disable-next-line @typescript-eslint/require-await +export const createClient = cache(async function createClient() { + const client = createMinioClient(); + + const bucketName = env.S3_BUCKET; + + const images = { + async all() { + const stream = client.listObjectsV2(bucketName); + + const images: Array<{ objectName: string }> = []; + + for await (const bucketItem of stream) { + const item = bucketItem as BucketItem; + const objectName = item.name; + assert(objectName); + images.push({ objectName }); + } + + return { images }; + }, + + async create( + fileName: string, + fileStream: Readable, + fileSize: number, + metadata: ItemBucketMetadata, + ) { + const objectName = generateObjectName(fileName); + + await client.putObject(bucketName, objectName, fileStream, fileSize, metadata); + + return { objectName }; + }, + }; + + const signedImageUrls = { + // FIXME: which options should we expose? + async get(objectName: string) { + const presignedUrl = await client.presignedGetObject(bucketName, objectName, 24 * 60 * 60); + const url = generateSignedImageUrl(presignedUrl, { + resizing_type: "fit", + width: 800, + gravity: { type: "no" }, + enlarge: 1, + }); + + return { url }; + }, + }; + + return { + images, + signedImageUrls, + }; +}); diff --git a/lib/server/images/create-minio-client.ts b/lib/server/images/create-minio-client.ts new file mode 100644 index 00000000..c20348f5 --- /dev/null +++ b/lib/server/images/create-minio-client.ts @@ -0,0 +1,14 @@ +import { Client } from "minio"; + +import { env } from "@/config/env.config"; + +export function createMinioClient() { + const client = new Client({ + endPoint: env.S3_HOST, + useSSL: env.S3_PROTOCOL === "https", + accessKey: env.S3_ACCESS_KEY, + secretKey: env.S3_SECRET_KEY, + }); + + return client; +} diff --git a/lib/server/images/generate-object-name.ts b/lib/server/images/generate-object-name.ts new file mode 100644 index 00000000..56d2c688 --- /dev/null +++ b/lib/server/images/generate-object-name.ts @@ -0,0 +1,8 @@ +import slugify from "@sindresorhus/slugify"; +import { v7 as uuidv7 } from "uuid"; + +export function generateObjectName(fileName: string): string { + const objectName = `${uuidv7()}-${slugify(fileName)}`; + + return objectName; +} diff --git a/lib/server/images/generate-signed-image-url.ts b/lib/server/images/generate-signed-image-url.ts new file mode 100644 index 00000000..b78fea2a --- /dev/null +++ b/lib/server/images/generate-signed-image-url.ts @@ -0,0 +1,18 @@ +import { generateImageUrl, type IGenerateImageUrl } from "@imgproxy/imgproxy-node"; + +import { env } from "@/config/env.config"; + +export type Options = NonNullable; + +export function generateSignedImageUrl(presignedUrl: string, options: Options): string { + const url = generateImageUrl({ + endpoint: env.IMGPROXY_BASE_URL, + url: presignedUrl, + /** @see @see https://github.com/imgproxy/imgproxy-js-core/pull/26 */ + options, + salt: env.IMGPROXY_SALT, + key: env.IMGPROXY_KEY, + }); + + return url; +} diff --git a/lib/server/images/get-image-dimensions.ts b/lib/server/images/get-image-dimensions.ts new file mode 100644 index 00000000..31fe5523 --- /dev/null +++ b/lib/server/images/get-image-dimensions.ts @@ -0,0 +1,77 @@ +import "server-only"; + +import { createReadStream } from "node:fs"; +import { join } from "node:path"; +import { ReadableStream } from "node:stream/web"; + +import { assert } from "@acdh-oeaw/lib"; +import { imageDimensionsFromStream } from "image-dimensions"; + +const publicFolder = join(process.cwd(), "public"); + +export async function getImageDimensions( + src: string, +): Promise<{ height: number; width: number } | null> { + if (URL.canParse(src)) { + const response = await fetch(src); + + if (!response.ok || response.body == null) return null; + + const contentType = response.headers.get("content-type"); + + if (!contentType?.startsWith("image/")) return null; + + const dimensions = + contentType === "image/svg+xml" + ? await getSvgDimensions(response.body as ReadableStream) + : await imageDimensionsFromStream(response.body); + + return dimensions ?? null; + } + + assert(src.startsWith("/"), "Images with relative paths are not supported."); + + const stream = ReadableStream.from(createReadStream(join(publicFolder, src))); + + const dimensions = src.endsWith(".svg") + ? await getSvgDimensions(stream as ReadableStream) + : // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any + await imageDimensionsFromStream(stream as any); + + return dimensions ?? null; +} + +const widthRegex = /]*\swidth=['"](\d+)['"]/i; +const heightRegex = /]*\sheight=['"](\d+)['"]/i; +const viewBoxRegex = /]*\sviewBox=['"](\d+) (\d+) (\d+) (\d+)['"]/i; + +async function getSvgDimensions(stream: ReadableStream) { + const chunks = []; + const decoder = new TextDecoder(); + + for await (const chunk of stream) { + chunks.push(...chunk); + const text = decoder.decode(new Uint8Array(chunks)); + + const widthMatch = widthRegex.exec(text); + const heightMatch = heightRegex.exec(text); + + if (widthMatch && heightMatch) { + return { + width: Number.parseFloat(widthMatch[1]!), + height: Number.parseFloat(heightMatch[1]!), + }; + } + + const viewBoxMatch = viewBoxRegex.exec(text); + + if (viewBoxMatch) { + return { + width: Number.parseFloat(viewBoxMatch[3]!) - Number.parseFloat(viewBoxMatch[1]!), + height: Number.parseFloat(viewBoxMatch[4]!) - Number.parseFloat(viewBoxMatch[2]!), + }; + } + } + + return null; +} diff --git a/lib/server/rate-limit/global-rate-limit.ts b/lib/server/rate-limit/global-rate-limit.ts new file mode 100644 index 00000000..cc56a5b6 --- /dev/null +++ b/lib/server/rate-limit/global-rate-limit.ts @@ -0,0 +1,41 @@ +import { headers } from "next/headers"; + +import { RefillingTokenBucket } from "@/lib/server/rate-limit/rate-limiter"; + +export const globalBucket = new RefillingTokenBucket(100, 1); + +export async function globalGETRateLimit(): Promise { + /** + * Assumes `x-forwarded-for` header will always be defined. + * + * In acdh-ch infrastructure, `x-forwarded-for` actually holds the ip of the nginx ingress. + * Ask a sysadmin to enable "proxy-protocol" in haproxy to receive actual ip addresses. + */ + const headersList = await headers(); + + const clientIP = headersList.get("X-Forwarded-For"); + + if (clientIP == null) { + return true; + } + + return globalBucket.consume(clientIP, 1); +} + +export async function globalPOSTRateLimit(): Promise { + /** + * Assumes `x-forwarded-for` header will always be defined. + * + * In acdh-ch infrastructure, `x-forwarded-for` actually holds the ip of the nginx ingress. + * Ask a sysadmin to enable "proxy-protocol" in haproxy to receive actual ip addresses. + */ + const headersList = await headers(); + + const clientIP = headersList.get("X-Forwarded-For"); + + if (clientIP == null) { + return true; + } + + return globalBucket.consume(clientIP, 3); +} diff --git a/lib/server/rate-limit/rate-limiter.ts b/lib/server/rate-limit/rate-limiter.ts new file mode 100644 index 00000000..2ac91b83 --- /dev/null +++ b/lib/server/rate-limit/rate-limiter.ts @@ -0,0 +1,181 @@ +interface RefillBucket { + count: number; + refilledAt: number; +} + +export class RefillingTokenBucket<_Key> { + public max: number; + public refillIntervalSeconds: number; + + constructor(max: number, refillIntervalSeconds: number) { + this.max = max; + this.refillIntervalSeconds = refillIntervalSeconds; + } + + private storage = new Map<_Key, RefillBucket>(); + + public check(key: _Key, cost: number): boolean { + const bucket = this.storage.get(key); + + if (bucket == null) { + return true; + } + + const now = Date.now(); + const refill = Math.floor((now - bucket.refilledAt) / (this.refillIntervalSeconds * 1000)); + + if (refill > 0) { + return Math.min(bucket.count + refill, this.max) >= cost; + } + + return bucket.count >= cost; + } + + public consume(key: _Key, cost: number): boolean { + let bucket = this.storage.get(key); + + const now = Date.now(); + + if (bucket == null) { + bucket = { + count: this.max - cost, + refilledAt: now, + }; + + this.storage.set(key, bucket); + + return true; + } + + const refill = Math.floor((now - bucket.refilledAt) / (this.refillIntervalSeconds * 1000)); + + bucket.count = Math.min(bucket.count + refill, this.max); + bucket.refilledAt = now; + + if (bucket.count < cost) { + return false; + } + + bucket.count -= cost; + + this.storage.set(key, bucket); + + return true; + } +} + +interface ThrottlingCounter { + timeout: number; + updatedAt: number; +} + +export class Throttler<_Key> { + public timeoutSeconds: Array; + + private storage = new Map<_Key, ThrottlingCounter>(); + + constructor(timeoutSeconds: Array) { + this.timeoutSeconds = timeoutSeconds; + } + + public consume(key: _Key): boolean { + let counter = this.storage.get(key); + + const now = Date.now(); + + if (counter == null) { + counter = { + timeout: 0, + updatedAt: now, + }; + + this.storage.set(key, counter); + + return true; + } + + const allowed = now - counter.updatedAt >= this.timeoutSeconds[counter.timeout]! * 1000; + if (!allowed) { + return false; + } + + counter.updatedAt = now; + counter.timeout = Math.min(counter.timeout + 1, this.timeoutSeconds.length - 1); + + this.storage.set(key, counter); + + return true; + } + + public reset(key: _Key): void { + this.storage.delete(key); + } +} + +interface ExpiringBucket { + count: number; + createdAt: number; +} + +export class ExpiringTokenBucket<_Key> { + public max: number; + public expiresInSeconds: number; + + private storage = new Map<_Key, ExpiringBucket>(); + + constructor(max: number, expiresInSeconds: number) { + this.max = max; + this.expiresInSeconds = expiresInSeconds; + } + + public check(key: _Key, cost: number): boolean { + const bucket = this.storage.get(key); + + const now = Date.now(); + + if (bucket == null) { + return true; + } + + if (now - bucket.createdAt >= this.expiresInSeconds * 1000) { + return true; + } + + return bucket.count >= cost; + } + + public consume(key: _Key, cost: number): boolean { + let bucket = this.storage.get(key); + + const now = Date.now(); + + if (bucket == null) { + bucket = { + count: this.max - cost, + createdAt: now, + }; + + this.storage.set(key, bucket); + + return true; + } + + if (now - bucket.createdAt >= this.expiresInSeconds * 1000) { + bucket.count = this.max; + } + + if (bucket.count < cost) { + return false; + } + + bucket.count -= cost; + + this.storage.set(key, bucket); + + return true; + } + + public reset(key: _Key): void { + this.storage.delete(key); + } +} diff --git a/messages/en.json b/messages/en.json index 071ee647..1fabd3c5 100644 --- a/messages/en.json +++ b/messages/en.json @@ -405,6 +405,12 @@ "success": "Successfully updated working group." } }, + "createImage": { + "errors": { + "DefaultImageCreationError": "Unable to create image." + }, + "success": "Successfully uploaded image." + }, "createOutreach": { "errors": { "default": "Could not create outreach." diff --git a/package.json b/package.json index fa57db0a..b967ded2 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "format:fix": "pnpm run format:check --write", "i18n:check": "i18n-check --format next-intl --locales ./messages/ --source en --unused ./app/ ./components/", "i18n:prepare": "tsx ./scripts/generate-i18n-message-types.ts", + "images:test-image-signing": "dotenv -c development -- tsx ./test-s3.ts", "lint:check": "run-p --continue-on-error \"lint:*:check\"", "lint:fix": "run-p --continue-on-error \"lint:*:fix\"", "lint:code:check": "eslint . --cache", @@ -61,6 +62,7 @@ "@acdh-oeaw/validate-env": "^0.0.3", "@citation-js/core": "^0.7.18", "@citation-js/plugin-csl": "^0.7.18", + "@imgproxy/imgproxy-node": "^1.1.0", "@internationalized/date": "^3.8.2", "@keystatic/core": "^0.5.48", "@keystatic/next": "^5.0.4", @@ -69,6 +71,7 @@ "@react-aria/utils": "^3.30.0", "@react-stately/data": "^3.13.2", "@sentry/nextjs": "^9.41.0", + "@sindresorhus/slugify": "^3.0.0", "@tiptap/extension-bold": "^3.5.1", "@tiptap/extension-document": "^3.5.1", "@tiptap/extension-link": "^3.5.1", @@ -84,7 +87,9 @@ "dompurify": "^3.2.7", "dset": "^3.1.4", "fast-glob": "^3.3.3", + "image-dimensions": "^2.5.0", "lucide-react": "^0.525.0", + "minio": "^8.0.6", "next": "^15.4.4", "next-intl": "^4.3.4", "nodemailer": "^7.0.5", @@ -97,6 +102,7 @@ "server-only": "^0.0.1", "sharp": "^0.34.3", "shiki": "^3.8.1", + "uuid": "^13.0.0", "valibot": "^1.1.0", "zod": "^3.25.76" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dee71731..36f145ca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,6 +24,9 @@ importers: '@citation-js/plugin-csl': specifier: ^0.7.18 version: 0.7.18(@citation-js/core@0.7.18) + '@imgproxy/imgproxy-node': + specifier: ^1.1.0 + version: 1.1.0 '@internationalized/date': specifier: ^3.8.2 version: 3.8.2 @@ -48,6 +51,9 @@ importers: '@sentry/nextjs': specifier: ^9.41.0 version: 9.41.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(next@15.4.4(@babel/core@7.28.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.54.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(webpack@5.95.0) + '@sindresorhus/slugify': + specifier: ^3.0.0 + version: 3.0.0 '@tiptap/extension-bold': specifier: ^3.5.1 version: 3.5.1(@tiptap/core@3.5.1(@tiptap/pm@3.5.1)) @@ -93,9 +99,15 @@ importers: fast-glob: specifier: ^3.3.3 version: 3.3.3 + image-dimensions: + specifier: ^2.5.0 + version: 2.5.0 lucide-react: specifier: ^0.525.0 version: 0.525.0(react@19.1.0) + minio: + specifier: ^8.0.6 + version: 8.0.6 next: specifier: ^15.4.4 version: 15.4.4(@babel/core@7.28.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.54.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -132,6 +144,9 @@ importers: shiki: specifier: ^3.8.1 version: 3.8.1 + uuid: + specifier: ^13.0.0 + version: 13.0.0 valibot: specifier: ^1.1.0 version: 1.1.0(typescript@5.8.3) @@ -1299,6 +1314,12 @@ packages: cpu: [x64] os: [win32] + '@imgproxy/imgproxy-js-core@1.5.0': + resolution: {integrity: sha512-kpCsA1pGyrS9Z7C6az4kk4xV2hDmQ+o7+TqzRpwquSgnfs+JpQQE0OSMFHuOL2zYkrIycP8nrnMMz7/I6n6vtA==} + + '@imgproxy/imgproxy-node@1.1.0': + resolution: {integrity: sha512-oxet1AVhvZbnBjZrq/moaLAb3LnJN1xDR2oQMam5lJy4equMngH3bfE7ePJ++Yz65tmNcINt9/X4oesJInAEjw==} + '@internationalized/date@3.8.2': resolution: {integrity: sha512-/wENk7CbvLbkUvX1tu0mwq49CVkkWpkXubGel6birjRPyo6uQ4nQpnq5xZu823zRCwwn82zgHrvgF1vZyvmVgA==} @@ -2648,10 +2669,18 @@ packages: resolution: {integrity: sha512-V9nR/W0Xd9TSGXpZ4iFUcFGhuOJtZX82Fzxj1YISlbSgKvIiNa7eLEZrT0vAraPOt++KHauIVNYgGRgjc13dXA==} engines: {node: '>=10'} + '@sindresorhus/slugify@3.0.0': + resolution: {integrity: sha512-SCrKh1zS96q+CuH5GumHcyQEVPsM4Ve8oE0E6tw7AAhGq50K8ojbTUOQnX/j9Mhcv/AXiIsbCfquovyGOo5fGw==} + engines: {node: '>=20'} + '@sindresorhus/transliterate@0.1.2': resolution: {integrity: sha512-5/kmIOY9FF32nicXH+5yLNTX4NJ4atl7jRgqAJuIn/iyDFXBktOKDxCvyGE/EzmF4ngSUvjXxQUQlQiZ5lfw+w==} engines: {node: '>=10'} + '@sindresorhus/transliterate@2.0.0': + resolution: {integrity: sha512-lRx63oCHxeJ90DqIgmbxH1PQmiBDY1wVaLzB4hK0d/xS5BrG1iZO3HdCJS/DQJk6GJ8xHDev8OMI7iGxvE1ZUA==} + engines: {node: '>=20'} + '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} @@ -3213,6 +3242,9 @@ packages: '@xtuc/long@4.2.2': resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} + '@zxing/text-encoding@0.9.0': + resolution: {integrity: sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==} + JSONStream@1.3.5: resolution: {integrity: sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==} hasBin: true @@ -3363,6 +3395,9 @@ packages: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + available-typed-arrays@1.0.7: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} @@ -3422,6 +3457,9 @@ packages: bl@5.1.0: resolution: {integrity: sha512-tv1ZJHLfTDnXE6tMHv73YgSJaWR2AFuPwMntBe7XL/GBFHnT0CLnsHMogfk5+GzCDC5ZWarSCYaIGATZt9dNsQ==} + block-stream2@2.1.0: + resolution: {integrity: sha512-suhjmLI57Ewpmq00qaygS8UgEq2ly2PCItenIyhMqVjo4t4pGzqMvfgJuX8iWTeSDdfSSqS6j38fL4ToNL7Pfg==} + boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} @@ -3450,11 +3488,18 @@ packages: resolution: {integrity: sha512-a4zUsWtA1uns1K7p9rExYVYG99rdKeGRymW0qOCNkvDPHQxVi3yVyJHhQbM3EZwdt2E0mnhr5e0c/bPpJ7p3Wg==} engines: {node: 10.* || >= 12.*} + browser-or-node@2.1.1: + resolution: {integrity: sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg==} + browserslist@4.25.1: resolution: {integrity: sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-crc32@1.0.0: + resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==} + engines: {node: '>=8.0.0'} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -3795,6 +3840,10 @@ packages: decode-named-character-reference@1.2.0: resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==} + decode-uri-component@0.2.2: + resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==} + engines: {node: '>=0.10'} + deep-eql@5.0.2: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} @@ -4280,6 +4329,10 @@ packages: fast-uri@3.0.6: resolution: {integrity: sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==} + fast-xml-parser@4.5.3: + resolution: {integrity: sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig==} + hasBin: true + fastest-levenshtein@1.0.16: resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==} engines: {node: '>= 4.9.1'} @@ -4315,6 +4368,10 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + filter-obj@1.1.0: + resolution: {integrity: sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==} + engines: {node: '>=0.10.0'} + find-root@1.1.0: resolution: {integrity: sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==} @@ -4638,6 +4695,11 @@ packages: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} + image-dimensions@2.5.0: + resolution: {integrity: sha512-CKZPHjAEtSg9lBV9eER0bhNn/yrY7cFEQEhkwjLhqLY+Na8lcP1pEyWsaGMGc8t2qbKWA/tuqbhFQpOKGN72Yw==} + engines: {node: '>=18'} + hasBin: true + immer@9.0.21: resolution: {integrity: sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==} @@ -4679,12 +4741,20 @@ packages: intl-messageformat@10.7.16: resolution: {integrity: sha512-UmdmHUmp5CIKKjSoE10la5yfU+AYJAaiYLsodbjL4lji83JNvgOQUjGaGhGrpFCb0Uh7sl7qfP1IyILa8Z40ug==} + ipaddr.js@2.2.0: + resolution: {integrity: sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==} + engines: {node: '>= 10'} + is-alphabetical@2.0.1: resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} is-alphanumerical@2.0.1: resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} + is-arguments@1.2.0: + resolution: {integrity: sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==} + engines: {node: '>= 0.4'} + is-array-buffer@3.0.5: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} @@ -5432,6 +5502,10 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minio@8.0.6: + resolution: {integrity: sha512-sOeh2/b/XprRmEtYsnNRFtOqNRTPDvYtMWh+spWlfsuCV/+IdxNeKVUMKLqI7b5Dr07ZqCPuaRGU/rB9pZYVdQ==} + engines: {node: ^16 || ^18 || >=20} + minipass@4.2.8: resolution: {integrity: sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==} engines: {node: '>=8'} @@ -5953,6 +6027,10 @@ packages: quansync@0.2.10: resolution: {integrity: sha512-t41VRkMYbkHyCYmOvx/6URnN80H7k4X0lLdBMGsz+maAwrJQYB1djpV6vHrQIBE0WBSGqhtEHrK9U3DWWH8v7A==} + query-string@7.1.3: + resolution: {integrity: sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==} + engines: {node: '>=6'} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -6204,6 +6282,9 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + sax@1.4.1: + resolution: {integrity: sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==} + scheduler@0.26.0: resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==} @@ -6378,6 +6459,10 @@ packages: space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + split-on-first@1.1.0: + resolution: {integrity: sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==} + engines: {node: '>=6'} + split2@4.2.0: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} @@ -6403,12 +6488,22 @@ packages: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} + stream-chain@2.2.5: + resolution: {integrity: sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA==} + stream-composer@1.0.2: resolution: {integrity: sha512-bnBselmwfX5K10AH6L4c8+S5lgZMWI7ZYrz2rvYjCPB2DIMC4Ig8OpxGpNJSxRZ58oti7y1IcNvjBAz9vW5m4w==} + stream-json@1.9.1: + resolution: {integrity: sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw==} + streamx@2.22.1: resolution: {integrity: sha512-znKXEBxfatz2GBNK02kRnCXjV+AA4kjZIUxeWSr3UGirZMJfTE9uiwKHobnbgxWyL/JWro8tTq+vOqAK1/qbSA==} + strict-uri-encode@2.0.0: + resolution: {integrity: sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==} + engines: {node: '>=4'} + string-argv@0.3.2: resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} engines: {node: '>=0.6.19'} @@ -6475,6 +6570,9 @@ packages: strip-literal@3.0.0: resolution: {integrity: sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==} + strnum@1.1.2: + resolution: {integrity: sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==} + style-to-js@1.1.17: resolution: {integrity: sha512-xQcBGDxJb6jjFCTzvQtfiPn6YvvP2O8U1MDIPNfJQlWMYfktPy+iGsHE7cssjs7y84d9fQaK4UF3RIJaAHSoYA==} @@ -6633,6 +6731,9 @@ packages: through2@2.0.5: resolution: {integrity: sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==} + through2@4.0.2: + resolution: {integrity: sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==} + through@2.3.8: resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} @@ -6871,6 +6972,13 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + util@0.12.5: + resolution: {integrity: sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==} + + uuid@13.0.0: + resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==} + hasBin: true + uuid@9.0.1: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} hasBin: true @@ -6998,6 +7106,9 @@ packages: resolution: {integrity: sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==} engines: {node: '>=10.13.0'} + web-encoding@1.1.5: + resolution: {integrity: sha512-HYLeVCdJ0+lBYV2FvNZmv3HJ2Nt0QYXqZojk3d9FJOLkwnuhzM9tmamh8d7HPM8QqjKH8DeHkFTx+CFlWpZZDA==} + webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} @@ -7110,6 +7221,14 @@ packages: utf-8-validate: optional: true + xml2js@0.6.2: + resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==} + engines: {node: '>=4.0.0'} + + xmlbuilder@11.0.1: + resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} + engines: {node: '>=4.0'} + xmlbuilder@15.1.1: resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} engines: {node: '>=8.0'} @@ -8171,6 +8290,12 @@ snapshots: '@img/sharp-win32-x64@0.34.3': optional: true + '@imgproxy/imgproxy-js-core@1.5.0': {} + + '@imgproxy/imgproxy-node@1.1.0': + dependencies: + '@imgproxy/imgproxy-js-core': 1.5.0 + '@internationalized/date@3.8.2': dependencies: '@swc/helpers': 0.5.17 @@ -10299,11 +10424,18 @@ snapshots: '@sindresorhus/transliterate': 0.1.2 escape-string-regexp: 4.0.0 + '@sindresorhus/slugify@3.0.0': + dependencies: + '@sindresorhus/transliterate': 2.0.0 + escape-string-regexp: 5.0.0 + '@sindresorhus/transliterate@0.1.2': dependencies: escape-string-regexp: 2.0.0 lodash.deburr: 4.1.0 + '@sindresorhus/transliterate@2.0.0': {} + '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 @@ -10938,6 +11070,9 @@ snapshots: '@xtuc/long@4.2.2': {} + '@zxing/text-encoding@0.9.0': + optional: true + JSONStream@1.3.5: dependencies: jsonparse: 1.3.1 @@ -11094,6 +11229,8 @@ snapshots: async-function@1.0.0: {} + async@3.2.6: {} + available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.1.0 @@ -11150,6 +11287,10 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 + block-stream2@2.1.0: + dependencies: + readable-stream: 3.6.2 + boolbase@1.0.0: {} brace-expansion@1.1.12: @@ -11189,6 +11330,8 @@ snapshots: transitivePeerDependencies: - supports-color + browser-or-node@2.1.1: {} + browserslist@4.25.1: dependencies: caniuse-lite: 1.0.30001727 @@ -11196,6 +11339,8 @@ snapshots: node-releases: 2.0.19 update-browserslist-db: 1.1.3(browserslist@4.25.1) + buffer-crc32@1.0.0: {} + buffer-from@1.1.2: {} buffer@5.7.1: @@ -11520,6 +11665,8 @@ snapshots: dependencies: character-entities: 2.0.2 + decode-uri-component@0.2.2: {} + deep-eql@5.0.2: {} deep-is@0.1.4: {} @@ -12243,6 +12390,10 @@ snapshots: fast-uri@3.0.6: {} + fast-xml-parser@4.5.3: + dependencies: + strnum: 1.1.2 + fastest-levenshtein@1.0.16: {} fastq@1.19.1: @@ -12277,6 +12428,8 @@ snapshots: dependencies: to-regex-range: 5.0.1 + filter-obj@1.1.0: {} + find-root@1.1.0: {} find-up@5.0.0: @@ -12704,6 +12857,8 @@ snapshots: ignore@7.0.5: {} + image-dimensions@2.5.0: {} + immer@9.0.21: {} import-fresh@3.3.1: @@ -12748,6 +12903,8 @@ snapshots: '@formatjs/icu-messageformat-parser': 2.11.2 tslib: 2.8.1 + ipaddr.js@2.2.0: {} + is-alphabetical@2.0.1: {} is-alphanumerical@2.0.1: @@ -12755,6 +12912,11 @@ snapshots: is-alphabetical: 2.0.1 is-decimal: 2.0.1 + is-arguments@1.2.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + is-array-buffer@3.0.5: dependencies: call-bind: 1.0.8 @@ -13732,6 +13894,23 @@ snapshots: minimist@1.2.8: {} + minio@8.0.6: + dependencies: + async: 3.2.6 + block-stream2: 2.1.0 + browser-or-node: 2.1.1 + buffer-crc32: 1.0.0 + eventemitter3: 5.0.1 + fast-xml-parser: 4.5.3 + ipaddr.js: 2.2.0 + lodash: 4.17.21 + mime-types: 2.1.35 + query-string: 7.1.3 + stream-json: 1.9.1 + through2: 4.0.2 + web-encoding: 1.1.5 + xml2js: 0.6.2 + minipass@4.2.8: {} minipass@7.1.2: {} @@ -14262,6 +14441,13 @@ snapshots: quansync@0.2.10: {} + query-string@7.1.3: + dependencies: + decode-uri-component: 0.2.2 + filter-obj: 1.1.0 + split-on-first: 1.1.0 + strict-uri-encode: 2.0.0 + queue-microtask@1.2.3: {} quick-temp@0.1.8: @@ -14724,6 +14910,8 @@ snapshots: safer-buffer@2.1.2: {} + sax@1.4.1: {} + scheduler@0.26.0: {} schema-dts@1.1.5: {} @@ -14954,6 +15142,8 @@ snapshots: space-separated-tokens@2.0.2: {} + split-on-first@1.1.0: {} + split2@4.2.0: {} sprintf-js@1.1.3: {} @@ -14973,10 +15163,16 @@ snapshots: es-errors: 1.3.0 internal-slot: 1.1.0 + stream-chain@2.2.5: {} + stream-composer@1.0.2: dependencies: streamx: 2.22.1 + stream-json@1.9.1: + dependencies: + stream-chain: 2.2.5 + streamx@2.22.1: dependencies: fast-fifo: 1.3.2 @@ -14984,6 +15180,8 @@ snapshots: optionalDependencies: bare-events: 2.6.0 + strict-uri-encode@2.0.0: {} + string-argv@0.3.2: {} string-ts@2.2.1: {} @@ -15083,6 +15281,8 @@ snapshots: dependencies: js-tokens: 9.0.1 + strnum@1.1.2: {} + style-to-js@1.1.17: dependencies: style-to-object: 1.0.9 @@ -15270,6 +15470,10 @@ snapshots: readable-stream: 2.3.8 xtend: 4.0.2 + through2@4.0.2: + dependencies: + readable-stream: 3.6.2 + through@2.3.8: {} tiny-invariant@1.0.6: {} @@ -15540,6 +15744,16 @@ snapshots: util-deprecate@1.0.2: {} + util@0.12.5: + dependencies: + inherits: 2.0.4 + is-arguments: 1.2.0 + is-generator-function: 1.1.0 + is-typed-array: 1.1.15 + which-typed-array: 1.1.19 + + uuid@13.0.0: {} + uuid@9.0.1: {} valibot@1.1.0(typescript@5.8.3): @@ -15696,6 +15910,12 @@ snapshots: glob-to-regexp: 0.4.1 graceful-fs: 4.2.11 + web-encoding@1.1.5: + dependencies: + util: 0.12.5 + optionalDependencies: + '@zxing/text-encoding': 0.9.0 + webidl-conversions@3.0.1: {} webpack-bundle-analyzer@4.10.1: @@ -15853,6 +16073,13 @@ snapshots: ws@7.5.10: {} + xml2js@0.6.2: + dependencies: + sax: 1.4.1 + xmlbuilder: 11.0.1 + + xmlbuilder@11.0.1: {} + xmlbuilder@15.1.1: {} xtend@4.0.2: {} From 7992d5a950d7d90789267af6000840c0108ee026 Mon Sep 17 00:00:00 2001 From: Barbara Krautgartner Date: Thu, 2 Oct 2025 14:54:27 +0000 Subject: [PATCH 2/4] fix: remove unnecessary hook --- components/ui/blocks/tiptap-editor.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/components/ui/blocks/tiptap-editor.tsx b/components/ui/blocks/tiptap-editor.tsx index f5d5bbba..ce4766d0 100644 --- a/components/ui/blocks/tiptap-editor.tsx +++ b/components/ui/blocks/tiptap-editor.tsx @@ -1,6 +1,5 @@ "use client"; -import { usePreventScroll } from "@react-aria/overlays"; import { Bold } from "@tiptap/extension-bold"; import { Document } from "@tiptap/extension-document"; import { Link } from "@tiptap/extension-link"; @@ -75,8 +74,6 @@ export function TiptapEditor(props: TipTapEditorProps): ReactNode { } }; - usePreventScroll({ isDisabled: isOpen }); - const [content, setContent] = useState(defaultContent); const editor = useEditor({ editorProps: { From 7c78ab42ce097d87bd0ceb2bd9a21c8156e77d1a Mon Sep 17 00:00:00 2001 From: Barbara Krautgartner Date: Thu, 2 Oct 2025 15:53:29 +0000 Subject: [PATCH 3/4] fix: add missing env vars --- .github/workflows/build-deploy.yml | 9 +++++++++ .github/workflows/validate.yml | 18 ++++++++++++++++++ Dockerfile | 18 ++++++++++++++++++ 3 files changed, 45 insertions(+) diff --git a/.github/workflows/build-deploy.yml b/.github/workflows/build-deploy.yml index 9bc71603..2c7e5f8e 100644 --- a/.github/workflows/build-deploy.yml +++ b/.github/workflows/build-deploy.yml @@ -122,9 +122,18 @@ jobs: "EMAIL_SMTP_SERVER=${{ vars.K8S_SECRET_EMAIL_SMTP_SERVER }}" "DATABASE_DIRECT_URL=${{ secrets.K8S_SECRET_DATABASE_DIRECT_URL }}" "DATABASE_URL=${{ secrets.K8S_SECRET_DATABASE_URL }}" + "IMGPROXY_BASE_URL=${{ vars.K8S_SECRET_IMGPROXY_BASE_URL }}" + "IMGPROXY_KEY=${{ secrets.K8S_SECRET_IMGPROXY_KEY }}" + "IMGPROXY_SALT=${{ secrets.K8S_SECRET_IMGPROXY_SALT }}" "KEYSTATIC_GITHUB_CLIENT_ID=${{ secrets.K8S_SECRET_KEYSTATIC_GITHUB_CLIENT_ID }}" "KEYSTATIC_GITHUB_CLIENT_SECRET=${{ secrets.K8S_SECRET_KEYSTATIC_GITHUB_CLIENT_SECRET }}" "KEYSTATIC_SECRET=${{ secrets.K8S_SECRET_KEYSTATIC_SECRET }}" + "S3_ACCESS_KEY=${{ secrets.K8S_SECRET_S3_ACCESS_KEY }}" + "S3_SECRET_KEY=${{ secrets.K8S_SECRET_S3_SECRET_KEY }}" + "S3_BUCKET=${{ vars.K8S_SECRET_S3_BUCKET }}" + "S3_HOST=${{ vars.K8S_SECRET_S3_HOST }}" + "S3_PORT=${{ vars.K8S_SECRET_S3_PORT }}" + "S3_PROTOCOL=${{ vars.K8S_SECRET_S3_PROTOCOL }}" "SSHOC_MARKETPLACE_API_BASE_URL=${{ vars.K8S_SECRET_SSHOC_MARKETPLACE_API_BASE_URL }}" "SSHOC_MARKETPLACE_BASE_URL=${{ vars.K8S_SECRET_SSHOC_MARKETPLACE_BASE_URL }}" "SSHOC_MARKETPLACE_PASSWORD=${{ secrets.K8S_SECRET_SSHOC_MARKETPLACE_PASSWORD }}" diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index c62eb877..06a2a905 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -102,9 +102,18 @@ jobs: EMAIL_SMTP_PORT: "${{ vars.K8S_SECRET_TEST_EMAIL_SMTP_PORT }}" EMAIL_SMTP_SERVER: "${{ vars.K8S_SECRET_TEST_EMAIL_SMTP_SERVER }}" EMAIL_USER_NAME: "${{ secrets.K8S_SECRET_TEST_EMAIL_USER_NAME }}" + IMGPROXY_BASE_URL: "${{ vars.K8S_SECRET_IMGPROXY_BASE_URL }}" + IMGPROXY_KEY: "${{ secrets.K8S_SECRET_IMGPROXY_KEY }}" + IMGPROXY_SALT: "${{ secrets.K8S_SECRET_IMGPROXY_SALT }}" NEXT_PUBLIC_APP_BASE_URL: "http://localhost:3000" NEXT_PUBLIC_MATOMO_BASE_URL: "${{ vars.NEXT_PUBLIC_MATOMO_BASE_URL }}" NEXT_PUBLIC_REDMINE_ID: "${{ vars.SERVICE_ID }}" + S3_ACCESS_KEY: "${{ secrets.K8S_SECRET_S3_ACCESS_KEY }}" + S3_SECRET_KEY: "${{ secrets.K8S_SECRET_S3_SECRET_KEY }}" + S3_BUCKET: "${{ vars.K8S_SECRET_S3_BUCKET }}" + S3_HOST: "${{ vars.K8S_SECRET_S3_HOST }}" + S3_PORT: "${{ vars.K8S_SECRET_S3_PORT }}" + S3_PROTOCOL: "${{ vars.K8S_SECRET_S3_PROTOCOL }}" SSHOC_MARKETPLACE_API_BASE_URL: "${{ vars.K8S_SECRET_SSHOC_MARKETPLACE_API_BASE_URL }}" SSHOC_MARKETPLACE_BASE_URL: "${{ vars.K8S_SECRET_SSHOC_MARKETPLACE_BASE_URL }}" SSHOC_MARKETPLACE_PASSWORD: "${{ secrets.K8S_SECRET_SSHOC_MARKETPLACE_PASSWORD }}" @@ -126,9 +135,18 @@ jobs: EMAIL_SMTP_PORT: "${{ vars.K8S_SECRET_TEST_EMAIL_SMTP_PORT }}" EMAIL_SMTP_SERVER: "${{ vars.K8S_SECRET_TEST_EMAIL_SMTP_SERVER }}" EMAIL_USER_NAME: "${{ secrets.K8S_SECRET_TEST_EMAIL_USER_NAME }}" + IMGPROXY_BASE_URL: "${{ vars.K8S_SECRET_IMGPROXY_BASE_URL }}" + IMGPROXY_KEY: "${{ secrets.K8S_SECRET_IMGPROXY_KEY }}" + IMGPROXY_SALT: "${{ secrets.K8S_SECRET_IMGPROXY_SALT }}" NEXT_PUBLIC_APP_BASE_URL: "http://localhost:3000" NEXT_PUBLIC_MATOMO_BASE_URL: "${{ vars.NEXT_PUBLIC_MATOMO_BASE_URL }}" NEXT_PUBLIC_REDMINE_ID: "${{ vars.SERVICE_ID }}" + S3_ACCESS_KEY: "${{ secrets.K8S_SECRET_S3_ACCESS_KEY }}" + S3_SECRET_KEY: "${{ secrets.K8S_SECRET_S3_SECRET_KEY }}" + S3_BUCKET: "${{ vars.K8S_SECRET_S3_BUCKET }}" + S3_HOST: "${{ vars.K8S_SECRET_S3_HOST }}" + S3_PORT: "${{ vars.K8S_SECRET_S3_PORT }}" + S3_PROTOCOL: "${{ vars.K8S_SECRET_S3_PROTOCOL }}" SSHOC_MARKETPLACE_API_BASE_URL: "${{ vars.K8S_SECRET_SSHOC_MARKETPLACE_API_BASE_URL }}" SSHOC_MARKETPLACE_BASE_URL: "${{ vars.K8S_SECRET_SSHOC_MARKETPLACE_BASE_URL }}" SSHOC_MARKETPLACE_PASSWORD: "${{ secrets.K8S_SECRET_SSHOC_MARKETPLACE_PASSWORD }}" diff --git a/Dockerfile b/Dockerfile index 8a6783d6..4dd90985 100644 --- a/Dockerfile +++ b/Dockerfile @@ -55,9 +55,18 @@ RUN --mount=type=secret,id=AUTH_SECRET,uid=1000 \ --mount=type=secret,id=EMAIL_SMTP_SERVER,uid=1000 \ --mount=type=secret,id=DATABASE_DIRECT_URL,uid=1000 \ --mount=type=secret,id=DATABASE_URL,uid=1000 \ + --mount=type=secret,id=IMGPROXY_BASE_URL,uid=1000 \ + --mount=type=secret,id=IMGPROXY_KEY,uid=1000 \ + --mount=type=secret,id=IMGPROXY_SALT,uid=1000 \ --mount=type=secret,id=KEYSTATIC_GITHUB_CLIENT_ID,uid=1000 \ --mount=type=secret,id=KEYSTATIC_GITHUB_CLIENT_SECRET,uid=1000 \ --mount=type=secret,id=KEYSTATIC_SECRET,uid=1000 \ + --mount=type=secret,id=S3_ACCESS_KEY,uid=1000 \ + --mount=type=secret,id=S3_SECRET_KEY,uid=1000 \ + --mount=type=secret,id=S3_BUCKET,uid=1000 \ + --mount=type=secret,id=S3_HOST,uid=1000 \ + --mount=type=secret,id=S3_PORT,uid=1000 \ + --mount=type=secret,id=S3_PROTOCOL,uid=1000 \ --mount=type=secret,id=SSHOC_MARKETPLACE_API_BASE_URL,uid=1000 \ --mount=type=secret,id=SSHOC_MARKETPLACE_BASE_URL,uid=1000 \ --mount=type=secret,id=SSHOC_MARKETPLACE_PASSWORD,uid=1000 \ @@ -68,9 +77,18 @@ RUN --mount=type=secret,id=AUTH_SECRET,uid=1000 \ EMAIL_SMTP_SERVER=$(cat /run/secrets/EMAIL_SMTP_SERVER) \ DATABASE_DIRECT_URL=$(cat /run/secrets/DATABASE_DIRECT_URL) \ DATABASE_URL=$(cat /run/secrets/DATABASE_URL) \ + IMGPROXY_BASE_URL=$(cat /run/secrets/IMGPROXY_BASE_URL) \ + IMGPROXY_KEY=$(cat /run/secrets/IMGPROXY_KEY) \ + IMGPROXY_SALT=$(cat /run/secrets/IMGPROXY_SALT) \ KEYSTATIC_GITHUB_CLIENT_ID=$(cat /run/secrets/KEYSTATIC_GITHUB_CLIENT_ID) \ KEYSTATIC_GITHUB_CLIENT_SECRET=$(cat /run/secrets/KEYSTATIC_GITHUB_CLIENT_SECRET) \ KEYSTATIC_SECRET=$(cat /run/secrets/KEYSTATIC_SECRET) \ + S3_ACCESS_KEY=$(cat /run/secrets/S3_ACCESS_KEY) \ + S3_SECRET_KEY=$(cat /run/secrets/S3_SECRET_KEY) \ + S3_BUCKET=$(cat /run/secrets/S3_BUCKET) \ + S3_HOST=$(cat /run/secrets/S3_HOST) \ + S3_PORT=$(cat /run/secrets/S3_PORT) \ + S3_PROTOCOL=$(cat /run/secrets/S3_PROTOCOL) \ SSHOC_MARKETPLACE_API_BASE_URL=$(cat /run/secrets/SSHOC_MARKETPLACE_API_BASE_URL) \ SSHOC_MARKETPLACE_BASE_URL=$(cat /run/secrets/SSHOC_MARKETPLACE_BASE_URL) \ SSHOC_MARKETPLACE_PASSWORD=$(cat /run/secrets/SSHOC_MARKETPLACE_PASSWORD) \ From 1bf5cebc192cf3b9b0985e273ae30914697f69ae Mon Sep 17 00:00:00 2001 From: Barbara Krautgartner Date: Fri, 3 Oct 2025 14:42:09 +0000 Subject: [PATCH 4/4] refactor: presigning no longer necessary --- lib/actions/images.ts | 4 ++-- lib/server/images/create-client.ts | 5 ++--- lib/server/images/generate-signed-image-url.ts | 8 ++++++-- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/lib/actions/images.ts b/lib/actions/images.ts index 34c6926e..1ae61837 100644 --- a/lib/actions/images.ts +++ b/lib/actions/images.ts @@ -9,9 +9,9 @@ export async function getImageUrls() { const { images } = await client.images.all(); const imagesUpdated = await Promise.all( - images.map(async (image) => { + images.map((image) => { const { objectName } = image; - const { url } = await client.signedImageUrls.get(objectName); + const { url } = client.signedImageUrls.get(objectName); return { ...image, url, diff --git a/lib/server/images/create-client.ts b/lib/server/images/create-client.ts index 5a371df4..918e6ac1 100644 --- a/lib/server/images/create-client.ts +++ b/lib/server/images/create-client.ts @@ -47,9 +47,8 @@ export const createClient = cache(async function createClient() { const signedImageUrls = { // FIXME: which options should we expose? - async get(objectName: string) { - const presignedUrl = await client.presignedGetObject(bucketName, objectName, 24 * 60 * 60); - const url = generateSignedImageUrl(presignedUrl, { + get(objectName: string) { + const url = generateSignedImageUrl(objectName, bucketName, { resizing_type: "fit", width: 800, gravity: { type: "no" }, diff --git a/lib/server/images/generate-signed-image-url.ts b/lib/server/images/generate-signed-image-url.ts index b78fea2a..b5ed5af9 100644 --- a/lib/server/images/generate-signed-image-url.ts +++ b/lib/server/images/generate-signed-image-url.ts @@ -4,10 +4,14 @@ import { env } from "@/config/env.config"; export type Options = NonNullable; -export function generateSignedImageUrl(presignedUrl: string, options: Options): string { +export function generateSignedImageUrl( + objectName: string, + bucketName: string, + options: Options, +): string { const url = generateImageUrl({ endpoint: env.IMGPROXY_BASE_URL, - url: presignedUrl, + url: `s3://${bucketName}/${objectName}`, /** @see @see https://github.com/imgproxy/imgproxy-js-core/pull/26 */ options, salt: env.IMGPROXY_SALT,