diff --git a/package.json b/package.json index 48bed3a..ab5afca 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "@base-ui/react": "^1.6.0", "@better-auth/passkey": "^1.5.5", "@dnd-kit/react": "^0.4.0", - "@polinetwork/backend": "^0.17.0", + "@polinetwork/backend": "^0.17.1", "@t3-oss/env-core": "^0.13.10", "@tanstack/react-router": "1.170.17", "@tanstack/react-start": "1.168.27", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a1254fb..e210b08 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,8 +18,8 @@ importers: specifier: ^0.4.0 version: 0.4.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@polinetwork/backend': - specifier: ^0.17.0 - version: 0.17.0 + specifier: ^0.17.1 + version: 0.17.1 '@t3-oss/env-core': specifier: ^0.13.10 version: 0.13.11(typescript@6.0.3)(zod@4.3.5) @@ -311,6 +311,10 @@ packages: resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + '@base-ui/react@1.6.0': resolution: {integrity: sha512-/jzjTWJYXhRFO45Bev9lc3cHbmjzCMpUqbMZ2AgKy/z25mY9B6shGSNcXcjQar9n5doM0KYW1W8fcFv2jZBuMw==} engines: {node: '>=14.0.0'} @@ -506,6 +510,9 @@ packages: '@emnapi/runtime@1.11.1': resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + '@emnapi/wasi-threads@1.2.2': resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} @@ -699,8 +706,8 @@ packages: '@cfworker/json-schema': optional: true - '@mongodb-js/saslprep@1.4.11': - resolution: {integrity: sha512-o9rAHc0IpIjuPSxRutWpE1F62x7n+4mVS4rCNHkzhIUMQcc18bb6xEq5wd2NdN0WjepIyXIppRshYI2kQDOZVA==} + '@mongodb-js/saslprep@1.4.13': + resolution: {integrity: sha512-E3Sv4eCYAlKYUTx8S3ioQcDUscOif+8zZ5OnW1IzJ+Tt+EO+ke8mn+Y3FX6N1H79picwbdOavVOb1jPi2EOyrg==} '@napi-rs/wasm-runtime@1.1.6': resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} @@ -835,8 +842,8 @@ packages: resolution: {integrity: sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==} engines: {node: '>=20.0.0'} - '@polinetwork/backend@0.17.0': - resolution: {integrity: sha512-NfU0i4It86LOMsZ5xyOX8ccOcbazwsC8j/D/B4sBb9PaJjENAdyFS27l9I4i0ihM2MrcFBJixxDdmnPO7/D/Rg==} + '@polinetwork/backend@0.17.1': + resolution: {integrity: sha512-Zhu2ar2DpxxV49Z+RDe6TdDwh8zuAUP2fhPXSStisCjNDou37L3Yk0K8Ggp6XuOIsvxlikCaLSqwtt8hpDY2lA==} '@preact/signals-core@1.14.4': resolution: {integrity: sha512-HNB6HYeYKhQbJ1aKl+YRjrS4+QWHLKX6qKoUsfS/m0vqzsVaEBiZiaKbG/e+NKk2ch5ALQr/ihWaMHxiCuuWHA==} @@ -1584,8 +1591,8 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true - bson@7.2.0: - resolution: {integrity: sha512-YCEo7KjMlbNlyHhz7zAZNDpIpQbd+wOEHJYezv0nMYTn4x31eIUM2yomNNubclAt63dObUzKHWsBLJ9QcZNSnQ==} + bson@7.3.2: + resolution: {integrity: sha512-1w0ra+ho1cuE+w8jzwgzTFIimFtCfZeCoOsvIPQg6uyFCsp8M29U7bNNf5GrFh88TXbYg1g3TyKUGpd6O7q6zA==} engines: {node: '>=20.19.0'} bundle-name@4.1.0: @@ -1611,6 +1618,9 @@ packages: caniuse-lite@1.0.30001797: resolution: {integrity: sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==} + caniuse-lite@1.0.30001809: + resolution: {integrity: sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==} + chalk@5.6.2: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} @@ -2411,8 +2421,8 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - mongodb-connection-string-url@7.0.1: - resolution: {integrity: sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ==} + mongodb-connection-string-url@7.0.2: + resolution: {integrity: sha512-ZoS07RoFqpKYQwAk59qmrx8+jJHNHU30UjlU96QktiGn1ltvDr+vCznLX5DiUBLEpMAHatHNWV1nM/74ul66kA==} engines: {node: '>=20.19.0'} mongodb@7.1.0: @@ -2470,6 +2480,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + nanostores@1.2.0: resolution: {integrity: sha512-F0wCzbsH80G7XXo0Jd9/AVQC7ouWY6idUCTnMwW5t/Rv9W8qmO6endavDwg7TNp5GbugwSukFMVZqzPSrSMndg==} engines: {node: ^20.0.0 || >=22.0.0} @@ -2835,6 +2850,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + send@1.2.1: resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} engines: {node: '>= 18'} @@ -2845,10 +2865,20 @@ packages: peerDependencies: seroval: ^1.0 + seroval-plugins@1.5.6: + resolution: {integrity: sha512-HXuLAX2pu/UByPpaeo/TaMfvMIi+1QqIoPJYCcAtU8QkVNwgR6MPlGuCQTErV1JwraaMbYaWVIBX7mppzGLATQ==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + seroval@1.5.5: resolution: {integrity: sha512-bSjOuPcwPKLSJNhr9+bZxA20nQxVle5J5MNsYRVE6cIg7KpRLXGupymePavu0jrxlPiPsr4xGZSB8yUY2sH2sw==} engines: {node: '>=10'} + seroval@1.5.6: + resolution: {integrity: sha512-rVQVWjjSvlINzaQPZH5JFqsqEsIWdTxY3iJZCnTL/5gQbXIRooVZKI60tVCkOVfzcRPejboxO2t0P89dg5mQaA==} + engines: {node: '>=10'} + serve-static@2.2.1: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} @@ -2931,6 +2961,11 @@ packages: engines: {node: '>=20.16.0'} hasBin: true + srvx@0.11.22: + resolution: {integrity: sha512-LqZxxBDMKuMAZzFzJnDCkFOrs9MZQZr0LvHiO/SuSZVdQaXD7xQ5UWTUxheJrQPve1qk9MG2B/yttUvJxw8egQ==} + engines: {node: '>=20.16.0'} + hasBin: true + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} @@ -3594,6 +3629,12 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + optional: true + '@base-ui/react@1.6.0(@date-fns/tz@1.5.0)(@types/react@19.2.17)(date-fns@4.4.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@babel/runtime': 7.29.7 @@ -3787,6 +3828,11 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/runtime@1.11.3': + dependencies: + tslib: 2.8.1 + optional: true + '@emnapi/wasi-threads@1.2.2': dependencies: tslib: 2.8.1 @@ -3900,7 +3946,7 @@ snapshots: '@img/sharp-wasm32@0.34.5': dependencies: - '@emnapi/runtime': 1.11.1 + '@emnapi/runtime': 1.11.3 optional: true '@img/sharp-win32-arm64@0.34.5': @@ -3955,7 +4001,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@mongodb-js/saslprep@1.4.11': + '@mongodb-js/saslprep@1.4.13': dependencies: sparse-bitfield: 3.0.3 @@ -4124,7 +4170,7 @@ snapshots: tslib: 2.8.1 tsyringe: 4.10.0 - '@polinetwork/backend@0.17.0': {} + '@polinetwork/backend@0.17.1': {} '@preact/signals-core@1.14.4': {} @@ -4754,7 +4800,7 @@ snapshots: babel-plugin-react-compiler@1.0.0: dependencies: - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 optional: true balanced-match@4.0.4: {} @@ -4829,7 +4875,7 @@ snapshots: node-releases: 2.0.37 update-browserslist-db: 1.2.3(browserslist@4.28.2) - bson@7.2.0: {} + bson@7.3.2: {} bundle-name@4.1.0: dependencies: @@ -4851,6 +4897,9 @@ snapshots: caniuse-lite@1.0.30001797: {} + caniuse-lite@1.0.30001809: + optional: true + chalk@5.6.2: {} chokidar@5.0.0: @@ -5235,7 +5284,7 @@ snapshots: h3@2.0.1-rc.20(crossws@0.4.9(srvx@0.11.21)): dependencies: rou3: 0.8.1 - srvx: 0.11.21 + srvx: 0.11.22 optionalDependencies: crossws: 0.4.9(srvx@0.11.21) @@ -5485,16 +5534,16 @@ snapshots: minimist@1.2.8: {} - mongodb-connection-string-url@7.0.1: + mongodb-connection-string-url@7.0.2: dependencies: '@types/whatwg-url': 13.0.0 whatwg-url: 14.2.0 mongodb@7.1.0: dependencies: - '@mongodb-js/saslprep': 1.4.11 - bson: 7.2.0 - mongodb-connection-string-url: 7.0.1 + '@mongodb-js/saslprep': 1.4.13 + bson: 7.3.2 + mongodb-connection-string-url: 7.0.2 motion-dom@12.42.2: dependencies: @@ -5514,6 +5563,9 @@ snapshots: nanoid@3.3.12: {} + nanoid@3.3.18: + optional: true + nanostores@1.2.0: {} negotiator@1.0.0: {} @@ -5522,7 +5574,7 @@ snapshots: dependencies: '@next/env': 15.5.18 '@swc/helpers': 0.5.15 - caniuse-lite: 1.0.30001797 + caniuse-lite: 1.0.30001809 postcss: 8.4.31 react: 19.2.7 react-dom: 19.2.7(react@19.2.7) @@ -5721,7 +5773,7 @@ snapshots: postcss@8.4.31: dependencies: - nanoid: 3.3.12 + nanoid: 3.3.18 picocolors: 1.1.1 source-map-js: 1.2.1 optional: true @@ -5890,6 +5942,9 @@ snapshots: semver@7.8.2: {} + semver@7.8.5: + optional: true + send@1.2.1: dependencies: debug: 4.4.3 @@ -5910,8 +5965,16 @@ snapshots: dependencies: seroval: 1.5.5 + seroval-plugins@1.5.6(seroval@1.5.6): + dependencies: + seroval: 1.5.6 + optional: true + seroval@1.5.5: {} + seroval@1.5.6: + optional: true + serve-static@2.2.1: dependencies: encodeurl: 2.0.0 @@ -5969,7 +6032,7 @@ snapshots: dependencies: '@img/colour': 1.1.0 detect-libc: 2.1.2 - semver: 7.8.2 + semver: 7.8.5 optionalDependencies: '@img/sharp-darwin-arm64': 0.34.5 '@img/sharp-darwin-x64': 0.34.5 @@ -6040,8 +6103,8 @@ snapshots: solid-js@1.9.14: dependencies: csstype: 3.2.3 - seroval: 1.5.5 - seroval-plugins: 1.5.5(seroval@1.5.5) + seroval: 1.5.6 + seroval-plugins: 1.5.6(seroval@1.5.6) optional: true sonner@2.0.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7): @@ -6061,6 +6124,8 @@ snapshots: srvx@0.11.21: {} + srvx@0.11.22: {} + statuses@2.0.2: {} stdin-discarder@0.2.2: {} diff --git a/src/features/associations/association-dialogs.tsx b/src/features/associations/association-dialogs.tsx index 34f79b1..9f549ba 100644 --- a/src/features/associations/association-dialogs.tsx +++ b/src/features/associations/association-dialogs.tsx @@ -25,8 +25,9 @@ import { import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" import { Input } from "@/components/ui/input" import { Textarea } from "@/components/ui/textarea" -import { getAssociationInitials } from "./associations.constants" +import { ASSOCIATION_LOGO_MAX_SIZE, ASSOCIATION_LOGO_TYPES, getAssociationInitials } from "./associations.constants" import { createAssociation, deleteAssociation, editAssociation } from "./associations.functions" +import { associationSaveErrorMessage } from "./associations.validation" import type { Association } from "./types" export type AssociationDialogState = { mode: "create" } | { mode: "edit"; association: Association } @@ -72,9 +73,9 @@ export function AssociationDialog({ if (pending) return if ( logoFile && - (!["image/jpeg", "image/png", "image/svg+xml"].includes(logoFile.type) || logoFile.size > 2 * 1024 * 1024) + (!ASSOCIATION_LOGO_TYPES.some((type) => type === logoFile.type) || logoFile.size > ASSOCIATION_LOGO_MAX_SIZE) ) { - setError("Choose a JPG, PNG, or SVG logo no larger than 2 MB.") + setError("Choose a JPG, PNG, or SVG logo no larger than 1 MB.") return } @@ -92,14 +93,7 @@ export function AssociationDialog({ const saved = editing ? await editAssociationFn({ data }) : await createAssociationFn({ data }) onSaved(saved, dialog.mode) } catch (cause) { - const message = cause instanceof Error ? cause.message : "" - setError( - message.includes("NOT_FOUND") - ? "This association no longer exists." - : message.includes("LOGO") - ? "Choose a JPG, PNG, or SVG logo no larger than 2 MB." - : "The association could not be saved. Check the fields and your permissions." - ) + setError(associationSaveErrorMessage(cause)) } finally { setPending(false) } @@ -146,7 +140,7 @@ export function AssociationDialog({ - Optional JPG, PNG, or SVG, up to 2 MB. + Optional JPG, PNG, or SVG, up to 1 MB. diff --git a/src/features/associations/associations.constants.ts b/src/features/associations/associations.constants.ts index 306c4e9..98711b8 100644 --- a/src/features/associations/associations.constants.ts +++ b/src/features/associations/associations.constants.ts @@ -1,6 +1,9 @@ import { AtSign, Globe, Instagram, Linkedin, LinkIcon, Mail, Music2, Send, Youtube } from "lucide-react" import type { AssociationLink, AssociationLinks } from "./types" +export const ASSOCIATION_LOGO_MAX_SIZE = 1024 * 1024 +export const ASSOCIATION_LOGO_TYPES = ["image/jpeg", "image/png", "image/svg+xml"] as const + export const EMPTY_ASSOCIATION_LINKS: AssociationLinks = { email: null, website: null, diff --git a/src/features/associations/associations.functions.ts b/src/features/associations/associations.functions.ts index 6055a71..9aa9729 100644 --- a/src/features/associations/associations.functions.ts +++ b/src/features/associations/associations.functions.ts @@ -11,37 +11,32 @@ export const getAssociations = createServerFn() .middleware([adminMiddleware]) .handler(({ context }) => context.backend.web.associations.getAllAssociations.query()) -async function serializeLogo(logo: string | File | null) { - if (!(logo instanceof File)) return logo - const contents = Buffer.from(await logo.arrayBuffer()).toString("base64") - return `data:${logo.type};base64,${contents}` +function associationFormData(data: ReturnType) { + const formData = new FormData() + formData.set("name", data.name) + formData.set("descriptionIt", data.descriptionIt) + formData.set("descriptionEn", data.descriptionEn) + if (data.logo instanceof File) formData.set("logo", data.logo) + return formData } export const createAssociation = createServerFn({ method: "POST" }) .middleware([adminMiddleware]) .validator(parseCreateAssociationForm) - .handler(async ({ data, context }) => - context.backend.web.associations.addAssociation.mutate({ - name: data.name, - descriptionIt: data.descriptionIt, - descriptionEn: data.descriptionEn, - logo: await serializeLogo(data.logo), - createdBy: context.telegramId, - }) - ) + .handler(async ({ data, context }) => { + const formData = associationFormData(data) + formData.set("createdBy", String(context.telegramId)) + return context.backend.web.associations.addAssociation.mutate(formData) + }) export const editAssociation = createServerFn({ method: "POST" }) .middleware([adminMiddleware]) .validator(parseEditAssociationForm) .handler(async ({ data, context }) => { - const result = await context.backend.web.associations.editAssociation.mutate({ - id: data.id, - name: data.name, - descriptionIt: data.descriptionIt, - descriptionEn: data.descriptionEn, - logo: await serializeLogo(data.logo), - modifiedBy: context.telegramId, - }) + const formData = associationFormData(data) + formData.set("id", String(data.id)) + formData.set("modifiedBy", String(context.telegramId)) + const result = await context.backend.web.associations.editAssociation.mutate(formData) if ("error" in result) throw new Error(result.error) return result }) diff --git a/src/features/associations/associations.validation.ts b/src/features/associations/associations.validation.ts index 0969100..e599545 100644 --- a/src/features/associations/associations.validation.ts +++ b/src/features/associations/associations.validation.ts @@ -1,7 +1,7 @@ import { z } from "zod" +import { ASSOCIATION_LOGO_MAX_SIZE, ASSOCIATION_LOGO_TYPES } from "./associations.constants.ts" -const MAX_LOGO_SIZE = 2 * 1024 * 1024 -const ALLOWED_LOGO_TYPES = new Set(["image/jpeg", "image/png", "image/svg+xml"]) +const ALLOWED_LOGO_TYPES = new Set(ASSOCIATION_LOGO_TYPES) function requiredText(data: FormData, key: string, maximum: number) { const value = data.get(key) @@ -19,7 +19,7 @@ function optionalLogo(data: FormData) { return logo } if (!(logo instanceof File) || !ALLOWED_LOGO_TYPES.has(logo.type)) throw new Error("INVALID_LOGO_TYPE") - if (logo.size > MAX_LOGO_SIZE) throw new Error("LOGO_TOO_LARGE") + if (logo.size > ASSOCIATION_LOGO_MAX_SIZE) throw new Error("LOGO_TOO_LARGE") return logo } @@ -62,3 +62,21 @@ export const associationLinksInput = z.object({ }) export const associationIdInput = z.object({ id: z.number().int().positive() }) + +export function associationSaveErrorMessage(cause: unknown) { + const message = cause instanceof Error ? cause.message : "" + + if (message.includes("NOT_FOUND")) return "This association no longer exists." + if (message.includes("INVALID_NAME")) return "Enter an association name no longer than 200 characters." + if (message.includes("INVALID_DESCRIPTIONIT")) { + return "Enter an Italian description no longer than 20,000 characters." + } + if (message.includes("INVALID_DESCRIPTIONEN")) { + return "Enter an English description no longer than 20,000 characters." + } + if (message.includes("LOGO") || message.includes("file")) { + return "Choose a JPG, PNG, or SVG logo no larger than 1 MB." + } + + return "The association could not be saved. Check your permissions and try again." +} diff --git a/src/features/projects/projects-page.tsx b/src/features/projects/projects-page.tsx index d0e9bf1..d70ebd5 100644 --- a/src/features/projects/projects-page.tsx +++ b/src/features/projects/projects-page.tsx @@ -11,6 +11,7 @@ import { Button } from "@/components/ui/button" import { ProjectCard } from "./project-card" import { DEFAULT_PROJECT, getProjectCategoryLabel, PROJECT_CATEGORIES } from "./projects.constants" import { createProject, deleteProject, editProject, reorderProjects } from "./projects.functions" +import { projectSaveErrorMessage } from "./projects.validation" import type { Project, ProjectCategory, ProjectFormValues, ProjectReorder } from "./types" function formDataForProject(values: ProjectFormValues, id?: number) { @@ -195,14 +196,7 @@ export function ProjectsPage({ loadedProjects }: { loadedProjects: Project[] }) } return true } catch (cause) { - const message = cause instanceof Error ? cause.message : "" - toast.error( - message.includes("LOGO_TOO_LARGE") - ? "The logo must be no larger than 1 MB." - : message.includes("INVALID_LOGO_TYPE") - ? "Choose an SVG, PNG, or JPEG logo." - : "The project could not be saved. Check the fields and try again." - ) + toast.error(projectSaveErrorMessage(cause)) return false } } diff --git a/src/features/projects/projects.functions.ts b/src/features/projects/projects.functions.ts index dee2255..fcc6085 100644 --- a/src/features/projects/projects.functions.ts +++ b/src/features/projects/projects.functions.ts @@ -16,10 +16,15 @@ function projectFields(project: Project) { } } -async function resolveLogo(currentLogo: string | null, logoFile: File | null) { - if (!logoFile) return currentLogo - const content = Buffer.from(await logoFile.arrayBuffer()).toString("base64") - return `data:${logoFile.type};base64,${content}` +function projectFormData(data: ReturnType) { + const formData = new FormData() + formData.set("title", data.title) + formData.set("descriptionIt", data.descriptionIt) + formData.set("descriptionEn", data.descriptionEn) + formData.set("link", data.link ?? "") + formData.set("category", data.category) + if (data.logoFile) formData.set("logo", data.logoFile) + return formData } export const getProjects = createServerFn() @@ -33,12 +38,9 @@ export const createProject = createServerFn({ method: "POST" }) .middleware([adminMiddleware]) .validator(parseProjectForm) .handler(async ({ data, context }) => { - const { logoFile, ...fields } = data - const project = await context.backend.web.projects.addProject.mutate({ - ...fields, - logo: await resolveLogo(fields.logo, logoFile), - createdBy: context.telegramId, - }) + const formData = projectFormData(data) + formData.set("createdBy", String(context.telegramId)) + const project = await context.backend.web.projects.addProject.mutate(formData) return projectFields(project) }) @@ -50,12 +52,10 @@ export const editProject = createServerFn({ method: "POST" }) return { id, ...parseProjectForm(data) } }) .handler(async ({ data, context }) => { - const { logoFile, ...fields } = data - const result = await context.backend.web.projects.editProject.mutate({ - ...fields, - logo: await resolveLogo(fields.logo, logoFile), - modifiedBy: context.telegramId, - }) + const formData = projectFormData(data) + formData.set("id", String(data.id)) + formData.set("modifiedBy", String(context.telegramId)) + const result = await context.backend.web.projects.editProject.mutate(formData) if ("error" in result) throw new Error(result.error) return projectFields(result) }) diff --git a/src/features/projects/projects.validation.ts b/src/features/projects/projects.validation.ts index 8b63f0c..99a5839 100644 --- a/src/features/projects/projects.validation.ts +++ b/src/features/projects/projects.validation.ts @@ -51,3 +51,21 @@ export function parseProjectForm(data: FormData) { category: category as "news" | "general" | "deprecated", } } + +export function projectSaveErrorMessage(cause: unknown) { + const message = cause instanceof Error ? cause.message : "" + + if (message.includes("INVALID_LINK")) return "Enter a valid HTTP or HTTPS project URL." + if (message.includes("INVALID_TITLE")) return "Enter a project title no longer than 160 characters." + if (message.includes("INVALID_DESCRIPTIONIT")) { + return "Enter an Italian description no longer than 5,000 characters." + } + if (message.includes("INVALID_DESCRIPTIONEN")) { + return "Enter an English description no longer than 5,000 characters." + } + if (message.includes("INVALID_CATEGORY")) return "Choose a valid project category." + if (message.includes("LOGO_TOO_LARGE")) return "The logo must be no larger than 1 MB." + if (message.includes("INVALID_LOGO_TYPE")) return "Choose an SVG, PNG, or JPEG logo." + + return "The project could not be saved. Check your permissions and try again." +} diff --git a/tests/server-security.test.mjs b/tests/server-security.test.mjs index 79a9351..91d9644 100644 --- a/tests/server-security.test.mjs +++ b/tests/server-security.test.mjs @@ -4,10 +4,11 @@ import test from "node:test" import { parseProfilePictureForm } from "../src/features/account/account.validation.ts" import { associationLinksInput, + associationSaveErrorMessage, parseCreateAssociationForm, } from "../src/features/associations/associations.validation.ts" import { parseGuideForm } from "../src/features/guides/guides.validation.ts" -import { parseProjectForm } from "../src/features/projects/projects.validation.ts" +import { parseProjectForm, projectSaveErrorMessage } from "../src/features/projects/projects.validation.ts" import { forwardAuthRequest } from "../src/server/auth-proxy-core.ts" import { hasAdminRole, isAgentModeEnabled } from "../src/server/authorization.ts" import { getForwardedCookieHeaders } from "../src/server/request-headers.ts" @@ -212,6 +213,13 @@ test("association validation accepts bounded image uploads and strict public lin wrongType.set("logo", new File([new Uint8Array(8)], "logo.gif", { type: "image/gif" })) assert.throws(() => parseCreateAssociationForm(wrongType), /INVALID_LOGO_TYPE/) + const oversized = new FormData() + oversized.set("name", "Test association") + oversized.set("descriptionIt", "Descrizione") + oversized.set("descriptionEn", "Description") + oversized.set("logo", new File([new Uint8Array(1024 * 1024 + 1)], "logo.png", { type: "image/png" })) + assert.throws(() => parseCreateAssociationForm(oversized), /LOGO_TOO_LARGE/) + const validLinks = { id: 1, links: { @@ -233,3 +241,32 @@ test("association validation accepts bounded image uploads and strict public lin /Invalid URL/ ) }) + +test("web content save errors explain actionable validation failures", () => { + assert.equal(projectSaveErrorMessage(new Error("INVALID_LINK")), "Enter a valid HTTP or HTTPS project URL.") + assert.equal(projectSaveErrorMessage(new Error("LOGO_TOO_LARGE")), "The logo must be no larger than 1 MB.") + assert.equal( + associationSaveErrorMessage(new Error("INVALID_DESCRIPTIONEN")), + "Enter an English description no longer than 20,000 characters." + ) + assert.equal( + associationSaveErrorMessage(new Error("INVALID_LOGO_TYPE")), + "Choose a JPG, PNG, or SVG logo no larger than 1 MB." + ) +}) + +test("project and association mutations forward FormData to the backend", async () => { + const [projectsSource, associationsSource] = await Promise.all([ + readFile(new URL("../src/features/projects/projects.functions.ts", import.meta.url), "utf8"), + readFile(new URL("../src/features/associations/associations.functions.ts", import.meta.url), "utf8"), + ]) + + for (const source of [projectsSource, associationsSource]) { + assert.match(source, /new FormData\(\)/) + assert.doesNotMatch(source, /Buffer\.from/) + } + assert.match(projectsSource, /addProject\.mutate\(formData/) + assert.match(projectsSource, /editProject\.mutate\(formData/) + assert.match(associationsSource, /addAssociation\.mutate\(formData/) + assert.match(associationsSource, /editAssociation\.mutate\(formData/) +})