From fb6162772da68dd1c6cda588202def9e8812ac7b Mon Sep 17 00:00:00 2001 From: NKoelblen Date: Mon, 4 May 2026 13:44:03 +0200 Subject: [PATCH 1/4] feat: add 'pined' field to project schema and database migration Co-authored-by: Copilot --- api/migrations/202605041327.ts | 28 +++++++++++++++++++++++ api/src/graphql/schemas/projectSchema.ts | 2 ++ api/src/repositories/ProjectRepository.ts | 7 ++++-- api/src/types/projectTypes.ts | 2 ++ 4 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 api/migrations/202605041327.ts diff --git a/api/migrations/202605041327.ts b/api/migrations/202605041327.ts new file mode 100644 index 0000000..97b3a87 --- /dev/null +++ b/api/migrations/202605041327.ts @@ -0,0 +1,28 @@ +import { Pool } from "mariadb/*"; +import { MigrationParams } from "umzug"; + +/** + * Migration pour ajouter une colonne "mockup_embed" à la table "project". + */ + +export async function up({ context: pool }: MigrationParams) { + const conn = await pool.getConnection(); + try { + await conn.query( + `ALTER TABLE IF EXISTS project ADD COLUMN IF NOT EXISTS pined BOOLEAN;`, + ); + } finally { + conn.release(); + } +} + +export async function down({ context: pool }: MigrationParams) { + const conn = await pool.getConnection(); + try { + await conn.query( + `ALTER TABLE IF EXISTS project DROP COLUMN IF EXISTS pined;`, + ); + } finally { + conn.release(); + } +} diff --git a/api/src/graphql/schemas/projectSchema.ts b/api/src/graphql/schemas/projectSchema.ts index ce9cbd9..558e8ff 100644 --- a/api/src/graphql/schemas/projectSchema.ts +++ b/api/src/graphql/schemas/projectSchema.ts @@ -6,6 +6,7 @@ export const projectTypes = ` label: String! thumbnail: ID categories: [ID!] + pined: Boolean website: Website mockup: Mockup client: Client @@ -171,6 +172,7 @@ export const projectInputs = ` label: String! thumbnail: ID categories: [ID!] + pined: Boolean website: WebsiteInput mockup: MockupInput client: ClientInput diff --git a/api/src/repositories/ProjectRepository.ts b/api/src/repositories/ProjectRepository.ts index 3ac75af..093afeb 100644 --- a/api/src/repositories/ProjectRepository.ts +++ b/api/src/repositories/ProjectRepository.ts @@ -139,6 +139,7 @@ export default class ProjectRepository extends BaseRepository { slug: projectRow.slug, label: projectRow.label, thumbnail: projectRow.thumbnail_id, + pined: projectRow.pined, startDate: projectRow.start_date, endDate: projectRow.end_date, categories: relations.categories, @@ -260,13 +261,14 @@ export default class ProjectRepository extends BaseRepository { // Insère le projet principal await conn.query( `INSERT INTO project ( - id, slug, label, thumbnail_id, website_url, website_label, mockup_url, mockup_label, mockup_embed, client_label, client_logo_id, manager_name, manager_email, start_date, end_date, intro, presentation_context, presentation_client, presentation_issue, presentation_audience, need_features, need_functional_constraints, need_technical_constraints, organization_workload, organization_anticipation, organization_methodology, organization_evolution, organization_validation, feedback, feedback_client, kpis_issues, kpis_points, kpis_commits, kpis_pull_requests - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + id, slug, label, thumbnail_id, pined, website_url, website_label, mockup_url, mockup_label, mockup_embed, client_label, client_logo_id, manager_name, manager_email, start_date, end_date, intro, presentation_context, presentation_client, presentation_issue, presentation_audience, need_features, need_functional_constraints, need_technical_constraints, organization_workload, organization_anticipation, organization_methodology, organization_evolution, organization_validation, feedback, feedback_client, kpis_issues, kpis_points, kpis_commits, kpis_pull_requests + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ id, project.slug, project.label, project.thumbnail || null, + project.pined || false, project.website?.url || null, project.website?.label || null, project.mockup?.url || null, @@ -366,6 +368,7 @@ export default class ProjectRepository extends BaseRepository { slug: project.slug, label: project.label, thumbnail_id: project.thumbnail, + pined: project.pined, website_url: project.website?.url, website_label: project.website?.label, mockup_url: project.mockup?.url, diff --git a/api/src/types/projectTypes.ts b/api/src/types/projectTypes.ts index b101cc4..5244663 100644 --- a/api/src/types/projectTypes.ts +++ b/api/src/types/projectTypes.ts @@ -5,6 +5,7 @@ export interface Project { label: string; thumbnail?: string; categories?: string[]; + pined?: boolean; website?: { url: string; label?: string; @@ -65,6 +66,7 @@ export interface ProjectRow { slug: string; label: string; thumbnail_id?: string; + pined?: boolean; website_url?: string; website_label?: string; mockup_url?: string; From e6c199a064a347cab7a6b0ff45a891fde9e70365 Mon Sep 17 00:00:00 2001 From: NKoelblen Date: Mon, 4 May 2026 14:13:44 +0200 Subject: [PATCH 2/4] feat: set default value for 'section' column in project_stack migration --- api/migrations/202605041407.ts | 29 +++++++++++++++++++++++ api/src/repositories/ProjectRepository.ts | 4 ++-- 2 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 api/migrations/202605041407.ts diff --git a/api/migrations/202605041407.ts b/api/migrations/202605041407.ts new file mode 100644 index 0000000..fcb7142 --- /dev/null +++ b/api/migrations/202605041407.ts @@ -0,0 +1,29 @@ +import { Pool } from "mariadb/*"; +import { MigrationParams } from "umzug"; + +/** + * Migration pour définir une valeur par défaut vide sur la colonne "section" + * de la table "project_stack" tout en la conservant NOT NULL. + */ + +export async function up({ context: pool }: MigrationParams) { + const conn = await pool.getConnection(); + try { + await conn.query( + `ALTER TABLE IF EXISTS project_stack MODIFY COLUMN IF EXISTS section VARCHAR(255) NOT NULL DEFAULT '';`, + ); + } finally { + conn.release(); + } +} + +export async function down({ context: pool }: MigrationParams) { + const conn = await pool.getConnection(); + try { + await conn.query( + `ALTER TABLE IF EXISTS project_stack MODIFY COLUMN IF EXISTS section VARCHAR(255) NOT NULL;`, + ); + } finally { + conn.release(); + } +} diff --git a/api/src/repositories/ProjectRepository.ts b/api/src/repositories/ProjectRepository.ts index 093afeb..4d8f3af 100644 --- a/api/src/repositories/ProjectRepository.ts +++ b/api/src/repositories/ProjectRepository.ts @@ -343,7 +343,7 @@ export default class ProjectRepository extends BaseRepository { id, stack_id, version ?? null, - section ?? null, + section ?? "", ]), ); } @@ -546,7 +546,7 @@ export default class ProjectRepository extends BaseRepository { const input: StackRow[] = stacks.map((s) => ({ stack_id: s.id, version: s.version ?? null, - section: s.section ?? null, + section: s.section ?? "", })); const toAdd = input.filter( (s) => From 47ce8dd768b8850cf949400edd5e1f7247fd2ad6 Mon Sep 17 00:00:00 2001 From: NKoelblen Date: Mon, 4 May 2026 14:23:10 +0200 Subject: [PATCH 3/4] feat: enhance sanitizeString function to support entity preservation Co-authored-by: Copilot --- api/src/graphql/resolvers/projectResolver.ts | 22 +++++++++----- api/src/utils/stringUtils.ts | 30 ++++++++++++++++++-- 2 files changed, 43 insertions(+), 9 deletions(-) diff --git a/api/src/graphql/resolvers/projectResolver.ts b/api/src/graphql/resolvers/projectResolver.ts index 03a4dc0..dd7aec8 100644 --- a/api/src/graphql/resolvers/projectResolver.ts +++ b/api/src/graphql/resolvers/projectResolver.ts @@ -41,14 +41,18 @@ function sanitizeProjectInput(input: Partial>): void { throw new Error("Invalid website URL"); } if (input.website.label) - input.website.label = sanitizeString(input.website.label); + input.website.label = sanitizeString(input.website.label, { + preserveEntities: true, + }); } if (input.mockup) { if (input.mockup.url) { if (!isValidUrl(input.mockup.url)) throw new Error("Invalid mockup URL"); } if (input.mockup.label) - input.mockup.label = sanitizeString(input.mockup.label); + input.mockup.label = sanitizeString(input.mockup.label, { + preserveEntities: true, + }); if (input.mockup.images) { for (const [i, image] of input.mockup.images.entries()) { if (!validator.isUUID(image.id)) delete input.mockup.images[i]; @@ -61,15 +65,18 @@ function sanitizeProjectInput(input: Partial>): void { } if (input.client) { if (input.client.label) - input.client.label = sanitizeString(input.client.label); + input.client.label = sanitizeString(input.client.label, { + preserveEntities: true, + }); if (input.client.logo && !validator.isUUID(input.client.logo as string)) delete input.client.logo; } if (input.manager) { if (input.manager.name) - input.manager.name = sanitizeString(input.manager.name); + input.manager.name = sanitizeString(input.manager.name, { + preserveEntities: true, + }); if (input.manager.email) { - input.manager.email = sanitizeString(input.manager.email); if (!validator.isEmail(input.manager.email)) throw new Error("Invalid manager email"); } @@ -208,7 +215,7 @@ const projectResolver = { checkAuth(context); const input = { ..._args.input }; if (isEmpty(input.label)) throw new Error("Label is required"); - input.label = sanitizeString(input.label); + input.label = sanitizeString(input.label, { preserveEntities: true }); sanitizeProjectInput(input); if (isEmpty(input.slug)) input.slug = slugify(input.label); const result = await context.projectRepo.create(input); @@ -233,7 +240,8 @@ const projectResolver = { validateId(_args.id); const input = { ..._args.input, id: _args.id }; - if (input.label) input.label = sanitizeString(input.label); + if (input.label) + input.label = sanitizeString(input.label, { preserveEntities: true }); sanitizeProjectInput(input); const result = await context.projectRepo.update(_args.id, input); if (!result) throw new Error("Failed to update project"); diff --git a/api/src/utils/stringUtils.ts b/api/src/utils/stringUtils.ts index 47e1907..4b2c332 100644 --- a/api/src/utils/stringUtils.ts +++ b/api/src/utils/stringUtils.ts @@ -23,10 +23,36 @@ export function slugify(text: string): string { /** * Nettoie une chaîne pour éviter les injections XSS * @param {string} str La chaîne à nettoyer + * @param {{ preserveEntities?: boolean }} [options] Options de nettoyage * @return {string} La chaîne nettoyée */ -export function sanitizeString(str: string): string { - return validator.escape(str.trim()); +export function sanitizeString( + str: string, + options?: { preserveEntities?: boolean }, +): string { + const trimmed = str.trim(); + + if (!options?.preserveEntities) { + return validator.escape(trimmed); + } + + const preservedEntities = ["­", "​", " "]; + const placeholders = new Map(); + let protectedValue = trimmed; + + preservedEntities.forEach((entity, index) => { + const token = `__ENTITY_${index}__`; + const re = new RegExp(entity.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi"); + protectedValue = protectedValue.replace(re, token); + placeholders.set(token, entity); + }); + + let escaped = validator.escape(protectedValue); + placeholders.forEach((entity, token) => { + escaped = escaped.replaceAll(token, entity); + }); + + return escaped; } /** From 675cfe8a21d66df067a2121263242b8deb71b365 Mon Sep 17 00:00:00 2001 From: NKoelblen Date: Mon, 4 May 2026 14:45:43 +0200 Subject: [PATCH 4/4] fix: improve array mapping for versions and skills in sanitizeStackInput function Co-authored-by: Copilot --- api/src/graphql/resolvers/stackResolver.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/src/graphql/resolvers/stackResolver.ts b/api/src/graphql/resolvers/stackResolver.ts index a6c185f..4b10fb1 100644 --- a/api/src/graphql/resolvers/stackResolver.ts +++ b/api/src/graphql/resolvers/stackResolver.ts @@ -20,8 +20,8 @@ function sanitizeStackInput(input: Partial>) { if (input.label) input.label = sanitizeString(input.label); if (input.icon && !validator.isUUID(input.icon as string)) delete input.icon; if (input.description) input.description = sanitizeString(input.description); - if (input.versions) input.versions = input.versions.map(sanitizeString); - if (input.skills) input.skills = input.skills.map(sanitizeString); + if (input.versions) input.versions = input.versions.map((v) => sanitizeString(v)); + if (input.skills) input.skills = input.skills.map((s) => sanitizeString(s)); if (input.category && !validator.isUUID(input.category as string)) delete input.category; }