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/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/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/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; } 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..4d8f3af 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, @@ -341,7 +343,7 @@ export default class ProjectRepository extends BaseRepository { id, stack_id, version ?? null, - section ?? null, + section ?? "", ]), ); } @@ -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, @@ -543,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) => 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; 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; } /**