From 556302449584fccb1f7f44d1c2efd0663fbb3e86 Mon Sep 17 00:00:00 2001 From: Zakaria Mansouri Date: Sat, 21 Dec 2024 15:32:17 +0100 Subject: [PATCH 1/9] /getContribution/:id --- api/src/contribution/controller.ts | 13 +++++- api/src/contribution/repository.ts | 71 ++++++++++++++++++++++++++++++ api/src/contribution/types.ts | 12 +++++ 3 files changed, 94 insertions(+), 2 deletions(-) diff --git a/api/src/contribution/controller.ts b/api/src/contribution/controller.ts index 22f65106e..761dc0cf7 100644 --- a/api/src/contribution/controller.ts +++ b/api/src/contribution/controller.ts @@ -1,8 +1,8 @@ -import { Controller, Get } from "routing-controllers"; +import { Controller, Get, Param } from "routing-controllers"; import { Service } from "typedi"; import { ContributionRepository } from "./repository"; -import { GetContributionsResponse } from "./types"; +import { GetContributionResponse, GetContributionsResponse } from "./types"; @Service() @Controller("/Contributions") @@ -17,4 +17,13 @@ export class ContributionController { contributions, }; } + + @Get("/:id") + public async getContribution(@Param("id") id: string): Promise { + const contribution = await this.contributionRepository.findByIdWithStats(id); + + return { + contribution, + }; + } } diff --git a/api/src/contribution/repository.ts b/api/src/contribution/repository.ts index 89afbc0e9..234e1f4ae 100644 --- a/api/src/contribution/repository.ts +++ b/api/src/contribution/repository.ts @@ -148,4 +148,75 @@ export class ContributionRepository { return sortedUpdatedAt; } + + public async findByIdWithStats(id: string) { + const statement = sql` + SELECT + p.id as id, + p.name as name, + json_agg( + json_build_object('id', r.id, 'name', r.name, 'owner', r.owner, 'contributions', r.contributions) + ) AS repositories + FROM + (SELECT + r.id as id, + r.owner as owner, + r.name as name, + r.project_id as project_id, + json_agg( + json_build_object( + 'id', + c.id, + 'title', + c.title, + 'type', + c.type, + 'url', + c.url, + 'updated_at', + c.updated_at, + 'activity_count', + c.activity_count, + 'contributor', + json_build_object( + 'id', + cr.id, + 'name', + cr.name, + 'username', + cr.username, + 'avatar_url', + cr.avatar_url + ) + ) + ) AS contributions + FROM + ${contributionsTable} c + INNER JOIN + ${repositoriesTable} r ON c.repository_id = r.id + INNER JOIN + ${contributorsTable} cr ON c.contributor_id = cr.id + WHERE + c.id = ${id} + GROUP BY + r.id) AS r + INNER JOIN + ${projectsTable} p ON r.project_id = p.id + GROUP BY + p.id + `; + + const raw = await this.postgresService.db.execute(statement); + const entries = Array.from(raw); + const unStringifiedRaw = unStringifyDeep(entries); + + const reversed = reverseHierarchy(unStringifiedRaw, [ + { from: "repositories", setParentAs: "project" }, + { from: "contributions", setParentAs: "repository" }, + ]); + + const camelCased = camelCaseObject(reversed); + + return camelCased[0]; + } } diff --git a/api/src/contribution/types.ts b/api/src/contribution/types.ts index 8c9f54c2e..cb7bc9549 100644 --- a/api/src/contribution/types.ts +++ b/api/src/contribution/types.ts @@ -14,3 +14,15 @@ export interface GetContributionsResponse extends GeneralResponse { } >; } + +export interface GetContributionResponse extends GeneralResponse { + contribution: Pick< + ContributionEntity, + "id" | "title" | "type" | "url" | "updatedAt" | "activityCount" + > & { + repository: Pick & { + project: Pick; + }; + contributor: Pick; + }; +} From 7f48d22e01ee8059a6ab2fb13b6bbd040998d1f3 Mon Sep 17 00:00:00 2001 From: Zakaria Mansouri Date: Sat, 21 Dec 2024 17:35:02 +0100 Subject: [PATCH 2/9] /contribute/:id page --- api/src/app/endpoints.ts | 6 +- web/src/_entry/app.tsx | 7 +- web/src/components/contribution-card.tsx | 97 ++++++------- web/src/components/locale/dictionary.ts | 12 ++ .../pages/contribute/contribution/index.tsx | 128 ++++++++++++++++++ web/src/redux/actions/contribution.ts | 22 +++ web/src/redux/slices/contribution-page.ts | 21 +++ web/src/redux/store.tsx | 2 + 8 files changed, 245 insertions(+), 50 deletions(-) create mode 100644 web/src/pages/contribute/contribution/index.tsx create mode 100644 web/src/redux/actions/contribution.ts create mode 100644 web/src/redux/slices/contribution-page.ts diff --git a/api/src/app/endpoints.ts b/api/src/app/endpoints.ts index 133ff3093..6b5fedb2d 100644 --- a/api/src/app/endpoints.ts +++ b/api/src/app/endpoints.ts @@ -1,4 +1,4 @@ -import { GetContributionsResponse } from "src/contribution/types"; +import { GetContributionResponse, GetContributionsResponse } from "src/contribution/types"; import { GetContributorNameResponse, GetContributorResponse, @@ -34,6 +34,10 @@ export interface Endpoints { "api:Contributions": { response: GetContributionsResponse; }; + "api:Contributions/:id": { + response: GetContributionResponse; + params: { id: string }; + }; "api:Contributors": { response: GetContributorsResponse; }; diff --git a/web/src/_entry/app.tsx b/web/src/_entry/app.tsx index 7b39681d6..b705d3332 100644 --- a/web/src/_entry/app.tsx +++ b/web/src/_entry/app.tsx @@ -35,8 +35,11 @@ let routes: Array< }, { pageName: "contribute", - // @TODO-ZM: change this back once we have contribution page - path: "/contribute/:slug?", + path: "/contribute", + }, + { + pageName: "contribute/contribution", + path: "/contribute/*", }, { pageName: "team", diff --git a/web/src/components/contribution-card.tsx b/web/src/components/contribution-card.tsx index e678f7401..bb346d413 100644 --- a/web/src/components/contribution-card.tsx +++ b/web/src/components/contribution-card.tsx @@ -3,6 +3,7 @@ import React from "react"; import { Link } from "src/components/link"; import { useLocale } from "src/components/locale"; import { Markdown } from "src/components/markdown"; +import { getContributionURL } from "src/utils/contribution"; import { getElapsedTime } from "src/utils/elapsed-time"; export function ContributionCard({ @@ -16,57 +17,59 @@ export function ContributionCard({ return (
-
-
-

- -

- - {!compact && ( - <> - {contribution.repository.project.name} - - {contribution.repository.owner}/{contribution.repository.name} - - - )} -
+ +
+
+

+ +

+ {!compact && ( - + <> + {contribution.repository.project.name} + + {contribution.repository.owner}/{contribution.repository.name} + + )} -
- {contribution.activityCount > 0 && ( -
- - - - {contribution.activityCount} -
- )} - {!compact && ( -
- {getElapsedTime(contribution.updatedAt, localize("elapsed-time-suffixes"))} -
- )} - - {contribution.type === "ISSUE" - ? localize("contribute-read-issue") - : localize("contribute-review-changes")} - +
+ {!compact && ( + + )} +
+ {contribution.activityCount > 0 && ( +
+ + + + {contribution.activityCount} +
+ )} + {!compact && ( +
+ {getElapsedTime(contribution.updatedAt, localize("elapsed-time-suffixes"))} +
+ )} + + {contribution.type === "ISSUE" + ? localize("contribute-read-issue") + : localize("contribute-review-changes")} + +
-
+
); } diff --git a/web/src/components/locale/dictionary.ts b/web/src/components/locale/dictionary.ts index de763ffd7..489c6c22d 100644 --- a/web/src/components/locale/dictionary.ts +++ b/web/src/components/locale/dictionary.ts @@ -365,6 +365,18 @@ Besides the open tasks on [/Contribute](/Contribute) page, you can also contribu en: "Review changes", ar: "مراجعة التغييرات", }, + "contribution-title-pre": { + en: "Help with: ", + ar: "ساعد في: ", + }, + "contribution-title-post": { + en: " | DzCode i/o", + ar: " | DzCode i / o", + }, + "contribution-breadcrumbs-1": { + en: "Contributions", + ar: "المساهمات", + }, "elapsed-time-suffixes": { en: "y|mo|d|h|min|Just now", ar: " عام| شهر| يوم| ساعة| دقيقة| الآن", diff --git a/web/src/pages/contribute/contribution/index.tsx b/web/src/pages/contribute/contribution/index.tsx new file mode 100644 index 000000000..1d2d19e38 --- /dev/null +++ b/web/src/pages/contribute/contribution/index.tsx @@ -0,0 +1,128 @@ +import React, { useEffect, useMemo } from "react"; +import { useAppDispatch, useAppSelector } from "src/redux/store"; +import { useParams } from "react-router-dom"; +import { Redirect } from "src/components/redirect"; +import { fetchContributionAction } from "src/redux/actions/contribution"; +import { Helmet } from "react-helmet-async"; +import { Locale, useLocale } from "src/components/locale"; +import { getContributionURL } from "src/utils/contribution"; +import { Link } from "src/components/link"; +import { TryAgain } from "src/components/try-again"; +import { Loading } from "src/components/loading"; +import { Markdown } from "src/components/markdown"; +import { getElapsedTime } from "src/utils/elapsed-time"; + +// ts-prune-ignore-next +export default function Page(): JSX.Element { + const { localize } = useLocale(); + const { contribution } = useAppSelector((state) => state.contributionPage); + const dispatch = useAppDispatch(); + const { "*": contributionSlug } = useParams<{ "*": string }>(); + const contributionId = useMemo(() => { + // slug: [title slug]-[id: [provider]-[number]] + const id = contributionSlug?.split("-").slice(-2).join("-"); + return id; + }, [contributionSlug]); + + useEffect(() => { + dispatch(fetchContributionAction(contributionId)); + }, [dispatch, contributionId]); + + if (contribution === "404") { + return ; + } + + return ( +
+ {contribution !== "ERROR" && contribution !== null ? ( + + + {localize("contribution-title-pre")} {contribution.title}{" "} + {localize("contribution-title-post")} + + + {/* @TODO-ZM: add canonical url on all pages */} + + + ) : null} +
+
    +
  • + + + +
  • + {contribution !== "ERROR" && contribution !== null ?
  • {contribution.title}
  • : null} +
+
+
+ {contribution === "ERROR" ? ( + { + dispatch(fetchContributionAction(contributionId)); + }} + /> + ) : contribution === null ? ( + + ) : ( +
+ {/* TODO-ZM: more tailored design for /contribute/:slug page instead of copy-pasting components from /contribute */} +
+
+
+

+ +

+ + {contribution.repository.project.name} + + {contribution.repository.owner}/{contribution.repository.name} + +
+ +
+ {contribution.activityCount > 0 && ( +
+ + + + {contribution.activityCount} +
+ )} +
+ {getElapsedTime(contribution.updatedAt, localize("elapsed-time-suffixes"))} +
+ + {contribution.type === "ISSUE" + ? localize("contribute-read-issue") + : localize("contribute-review-changes")} + +
+
+
+
+
+ )} +
+
+ ); +} diff --git a/web/src/redux/actions/contribution.ts b/web/src/redux/actions/contribution.ts new file mode 100644 index 000000000..47626ccd8 --- /dev/null +++ b/web/src/redux/actions/contribution.ts @@ -0,0 +1,22 @@ +import { Action, ThunkAction } from "@reduxjs/toolkit"; +import { captureException } from "@sentry/react"; +import { contributionPageSlice } from "src/redux/slices/contribution-page"; +import { AppState } from "src/redux/store"; +import { fetchV2 } from "src/utils/fetch"; + +export const fetchContributionAction = + (id?: string): ThunkAction => + async (dispatch) => { + if (!id) { + dispatch(contributionPageSlice.actions.set({ contribution: "404" })); + return; + } + try { + dispatch(contributionPageSlice.actions.set({ contribution: null })); + const { contribution } = await fetchV2("api:Contributions/:id", { params: { id } }); + dispatch(contributionPageSlice.actions.set({ contribution })); + } catch (error) { + dispatch(contributionPageSlice.actions.set({ contribution: "ERROR" })); + captureException(error, { tags: { type: "WEB_FETCH" } }); + } + }; diff --git a/web/src/redux/slices/contribution-page.ts b/web/src/redux/slices/contribution-page.ts new file mode 100644 index 000000000..d74ae31cb --- /dev/null +++ b/web/src/redux/slices/contribution-page.ts @@ -0,0 +1,21 @@ +import { GetContributionResponse } from "@dzcode.io/api/dist/contribution/types"; +import { createSlice } from "@reduxjs/toolkit"; +import { setReducerFactory } from "src/redux/utils"; +import { Loadable } from "src/utils/loadable"; + +// ts-prune-ignore-next +export interface ContributionPageState { + contribution: Loadable; +} + +const initialState: ContributionPageState = { + contribution: null, +}; + +export const contributionPageSlice = createSlice({ + name: "contribution-page", + initialState, + reducers: { + set: setReducerFactory(), + }, +}); diff --git a/web/src/redux/store.tsx b/web/src/redux/store.tsx index ebb93ae27..0fd35902a 100644 --- a/web/src/redux/store.tsx +++ b/web/src/redux/store.tsx @@ -4,6 +4,7 @@ import { PropsWithChildren, useState } from "react"; import { Provider as ReduxProvider, useDispatch, useSelector } from "react-redux"; import { contributionsPageSlice } from "./slices/contributions-page"; +import { contributionPageSlice } from "./slices/contribution-page"; import { contributorsPageSlice } from "./slices/contributors-page"; import { landingPageSlice } from "./slices/landing-page"; import { projectsPageSlice } from "./slices/projects-page"; @@ -20,6 +21,7 @@ const makeAppStore = () => { contributorsPage: contributorsPageSlice.reducer, contributorPage: contributorPageSlice.reducer, contributionsPage: contributionsPageSlice.reducer, + contributionPage: contributionPageSlice.reducer, landingPage: landingPageSlice.reducer, }, }); From a5c73d3d0ef06c92518f5b69f773703f25d0917c Mon Sep 17 00:00:00 2001 From: Zakaria Mansouri Date: Sun, 29 Dec 2024 21:16:39 +0100 Subject: [PATCH 3/9] update link in ContributionCard to link to Contribution page --- web/src/components/contribution-card.tsx | 103 ++++++++++++----------- web/src/components/project-card.tsx | 1 + web/src/components/search.tsx | 7 +- 3 files changed, 59 insertions(+), 52 deletions(-) diff --git a/web/src/components/contribution-card.tsx b/web/src/components/contribution-card.tsx index bb346d413..03fc4b145 100644 --- a/web/src/components/contribution-card.tsx +++ b/web/src/components/contribution-card.tsx @@ -9,67 +9,68 @@ import { getElapsedTime } from "src/utils/elapsed-time"; export function ContributionCard({ contribution, compact = false, + onClick, }: { contribution: GetContributionsResponse["contributions"][number]; compact?: boolean; + onClick?: () => void; }) { const { localize } = useLocale(); return ( -
- -
-
-

- -

- + +
+
+

+ +

+ + {!compact && ( + <> + {contribution.repository.project.name} + + {contribution.repository.owner}/{contribution.repository.name} + + + )} +
{!compact && ( - <> - {contribution.repository.project.name} - - {contribution.repository.owner}/{contribution.repository.name} - - + + )} +
+ {contribution.activityCount > 0 && ( +
+ + + + {contribution.activityCount} +
+ )} + {!compact && ( +
+ {getElapsedTime(contribution.updatedAt, localize("elapsed-time-suffixes"))} +
)} -
- {!compact && ( - - )} -
- {contribution.activityCount > 0 && ( -
- - - - {contribution.activityCount} -
- )} - {!compact && ( -
- {getElapsedTime(contribution.updatedAt, localize("elapsed-time-suffixes"))} -
- )} - - {contribution.type === "ISSUE" - ? localize("contribute-read-issue") - : localize("contribute-review-changes")} - -
- -
+
+ ); } diff --git a/web/src/components/project-card.tsx b/web/src/components/project-card.tsx index 0a2bc67a0..41be808c4 100644 --- a/web/src/components/project-card.tsx +++ b/web/src/components/project-card.tsx @@ -17,6 +17,7 @@ export function ProjectCard({ href={getProjectURL(project)} dir="ltr" className="bg-base-300 w-full max-w-xs sm:max-w-sm flex flex-col rounded-lg border-base-200 border-2 overflow-hidden" + // TODO-OB: there's a bug here: when passing onClick to Link, the link no longer work as a SPA link, and instead causes a full reload of the page onClick={onClick} >

{project.name}

diff --git a/web/src/components/search.tsx b/web/src/components/search.tsx index 746ed78ee..e5a91c4f0 100644 --- a/web/src/components/search.tsx +++ b/web/src/components/search.tsx @@ -132,7 +132,12 @@ export function Search(): JSX.Element {
{contributionsList.map((contribution) => ( - + ))}
From a12a8cd8735d3cf1a32e31e30a731badddf47e84 Mon Sep 17 00:00:00 2001 From: Zakaria Mansouri Date: Sun, 29 Dec 2024 22:17:02 +0100 Subject: [PATCH 4/9] feat: add endpoint to retrieve contribution title by ID --- api/src/app/endpoints.ts | 10 +++++++++- api/src/contribution/controller.ts | 19 +++++++++++++++++-- api/src/contribution/repository.ts | 21 +++++++++++++++++++++ api/src/contribution/types.ts | 4 ++++ 4 files changed, 51 insertions(+), 3 deletions(-) diff --git a/api/src/app/endpoints.ts b/api/src/app/endpoints.ts index 6b5fedb2d..b67d6b1a6 100644 --- a/api/src/app/endpoints.ts +++ b/api/src/app/endpoints.ts @@ -1,4 +1,8 @@ -import { GetContributionResponse, GetContributionsResponse } from "src/contribution/types"; +import { + GetContributionResponse, + GetContributionsResponse, + GetContributionTitleResponse, +} from "src/contribution/types"; import { GetContributorNameResponse, GetContributorResponse, @@ -38,6 +42,10 @@ export interface Endpoints { response: GetContributionResponse; params: { id: string }; }; + "api:contributions/:id/title": { + response: GetContributionTitleResponse; + params: { id: string }; + }; "api:Contributors": { response: GetContributorsResponse; }; diff --git a/api/src/contribution/controller.ts b/api/src/contribution/controller.ts index 761dc0cf7..5fe68b4ba 100644 --- a/api/src/contribution/controller.ts +++ b/api/src/contribution/controller.ts @@ -1,8 +1,12 @@ -import { Controller, Get, Param } from "routing-controllers"; +import { Controller, Get, NotFoundError, Param } from "routing-controllers"; import { Service } from "typedi"; import { ContributionRepository } from "./repository"; -import { GetContributionResponse, GetContributionsResponse } from "./types"; +import { + GetContributionTitleResponse, + GetContributionResponse, + GetContributionsResponse, +} from "./types"; @Service() @Controller("/Contributions") @@ -26,4 +30,15 @@ export class ContributionController { contribution, }; } + + @Get("/:id/title") + public async getContributionTitle( + @Param("id") id: string, + ): Promise { + const contribution = await this.contributionRepository.findTitle(id); + + if (!contribution) throw new NotFoundError("Contribution not found"); + + return { contribution }; + } } diff --git a/api/src/contribution/repository.ts b/api/src/contribution/repository.ts index 234e1f4ae..3aa7cc3eb 100644 --- a/api/src/contribution/repository.ts +++ b/api/src/contribution/repository.ts @@ -14,6 +14,27 @@ import { ContributionRow, contributionsTable } from "./table"; export class ContributionRepository { constructor(private readonly postgresService: PostgresService) {} + public async findTitle(contributionId: string) { + const statement = sql` + SELECT + ${contributionsTable.title} + FROM + ${contributionsTable} + WHERE + ${contributionsTable.id} = ${contributionId} + `; + + const raw = await this.postgresService.db.execute(statement); + const entries = Array.from(raw); + const entry = entries[0]; + + if (!entry) return null; + + const unStringifiedRaw = unStringifyDeep(entry); + const camelCased = camelCaseObject(unStringifiedRaw); + return camelCased; + } + public async findForProject(projectId: string) { const statement = sql` SELECT diff --git a/api/src/contribution/types.ts b/api/src/contribution/types.ts index cb7bc9549..d57f04efd 100644 --- a/api/src/contribution/types.ts +++ b/api/src/contribution/types.ts @@ -26,3 +26,7 @@ export interface GetContributionResponse extends GeneralResponse { contributor: Pick; }; } + +export interface GetContributionTitleResponse extends GeneralResponse { + contribution: Pick; +} From 71b4908ae69ecaa61f1e4f26d6f969dac2c37dbc Mon Sep 17 00:00:00 2001 From: Zakaria Mansouri Date: Sun, 29 Dec 2024 22:18:25 +0100 Subject: [PATCH 5/9] feat: implement contribution page metadata --- .../functions/ar/contribute/[slug].ts | 3 + web/cloudflare/functions/contribute/[slug].ts | 3 + web/cloudflare/handler/contribution.ts | 71 +++++++++++++++++++ 3 files changed, 77 insertions(+) create mode 100644 web/cloudflare/functions/ar/contribute/[slug].ts create mode 100644 web/cloudflare/functions/contribute/[slug].ts create mode 100644 web/cloudflare/handler/contribution.ts diff --git a/web/cloudflare/functions/ar/contribute/[slug].ts b/web/cloudflare/functions/ar/contribute/[slug].ts new file mode 100644 index 000000000..b006b233e --- /dev/null +++ b/web/cloudflare/functions/ar/contribute/[slug].ts @@ -0,0 +1,3 @@ +import { Env, handleContributionRequest } from "handler/contribution"; + +export const onRequest: PagesFunction = handleContributionRequest; diff --git a/web/cloudflare/functions/contribute/[slug].ts b/web/cloudflare/functions/contribute/[slug].ts new file mode 100644 index 000000000..b006b233e --- /dev/null +++ b/web/cloudflare/functions/contribute/[slug].ts @@ -0,0 +1,3 @@ +import { Env, handleContributionRequest } from "handler/contribution"; + +export const onRequest: PagesFunction = handleContributionRequest; diff --git a/web/cloudflare/handler/contribution.ts b/web/cloudflare/handler/contribution.ts new file mode 100644 index 000000000..f9963ac0f --- /dev/null +++ b/web/cloudflare/handler/contribution.ts @@ -0,0 +1,71 @@ +declare const htmlTemplate: string; // @ts-expect-error cloudflare converts this to a string using esbuild +import htmlTemplate from "../public/template.html"; +declare const notFoundEn: string; // @ts-expect-error cloudflare converts this to a string using esbuild +import notFoundEn from "../public/404.html"; +declare const notFoundAr: string; // @ts-expect-error cloudflare converts this to a string using esbuild +import notFoundAr from "../public/ar/404.html"; + +import { Environment, environments } from "@dzcode.io/utils/dist/config/environment"; +import { fsConfig } from "@dzcode.io/utils/dist/config"; +import { plainLocalize } from "@dzcode.io/web/dist/components/locale/utils"; +import { dictionary, AllDictionaryKeys } from "@dzcode.io/web/dist/components/locale/dictionary"; +import { LanguageEntity } from "@dzcode.io/models/dist/language"; +import { fetchV2Factory } from "@dzcode.io/utils/dist/fetch/factory"; +import { Endpoints } from "@dzcode.io/api/dist/app/endpoints"; + +export interface Env { + STAGE: Environment; +} + +export const handleContributionRequest: PagesFunction = async (context) => { + let stage = context.env.STAGE; + if (!environments.includes(stage)) { + console.log(`⚠️ No STAGE provided, falling back to "development"`); + stage = "development"; + } + + const pathName = new URL(context.request.url).pathname; + + const languageRegex = /^\/(ar|en)\//i; + const language = (pathName?.match(languageRegex)?.[1]?.toLowerCase() || + "en") as LanguageEntity["code"]; + const notFound = language === "ar" ? notFoundAr : notFoundEn; + + const contributionIdRegex = /contribute\/(.*)-(.*)-(.*)/; + const contributionId = + pathName?.match(contributionIdRegex)?.[2] + "-" + pathName?.match(contributionIdRegex)?.[3]; + + if (!contributionId) + return new Response(notFound, { + headers: { "content-type": "text/html; charset=utf-8" }, + status: 404, + }); + + const localize = (key: AllDictionaryKeys) => + plainLocalize(dictionary, language, key, "NO-TRANSLATION"); + + const fullstackConfig = fsConfig(stage); + const fetchV2 = fetchV2Factory(fullstackConfig); + + try { + const { contribution } = await fetchV2("api:contributions/:id/title", { + params: { id: contributionId }, + }); + const pageTitle = `${localize("contribution-title-pre")} ${contribution.title} ${localize("contribution-title-post")}`; + + const newData = htmlTemplate + .replace(/{{template-title}}/g, pageTitle) + .replace(/{{template-description}}/g, localize("contribute-description")) + .replace(/{{template-lang}}/g, language); + + return new Response(newData, { headers: { "content-type": "text/html; charset=utf-8" } }); + } catch (error) { + // @TODO-ZM: log error to sentry + console.error(error); + + return new Response(notFound, { + headers: { "content-type": "text/html; charset=utf-8" }, + status: 404, + }); + } +}; From 55f6906023b73025781aa860c6fc5e05bbf588a1 Mon Sep 17 00:00:00 2001 From: Zakaria Mansouri Date: Sun, 29 Dec 2024 22:42:29 +0100 Subject: [PATCH 6/9] GET /w/contributions-sitemap.xml --- api/src/app/endpoints.ts | 4 ++ api/src/contribution/controller.ts | 11 +++++ api/src/contribution/repository.ts | 16 ++++++ api/src/contribution/types.ts | 4 ++ .../functions/w/contributions-sitemap.xml.ts | 49 +++++++++++++++++++ 5 files changed, 84 insertions(+) create mode 100644 web/cloudflare/functions/w/contributions-sitemap.xml.ts diff --git a/api/src/app/endpoints.ts b/api/src/app/endpoints.ts index b67d6b1a6..875bd2441 100644 --- a/api/src/app/endpoints.ts +++ b/api/src/app/endpoints.ts @@ -1,5 +1,6 @@ import { GetContributionResponse, + GetContributionsForSitemapResponse, GetContributionsResponse, GetContributionTitleResponse, } from "src/contribution/types"; @@ -46,6 +47,9 @@ export interface Endpoints { response: GetContributionTitleResponse; params: { id: string }; }; + "api:contributions/for-sitemap": { + response: GetContributionsForSitemapResponse; + }; "api:Contributors": { response: GetContributorsResponse; }; diff --git a/api/src/contribution/controller.ts b/api/src/contribution/controller.ts index 5fe68b4ba..9e8e3b133 100644 --- a/api/src/contribution/controller.ts +++ b/api/src/contribution/controller.ts @@ -6,6 +6,7 @@ import { GetContributionTitleResponse, GetContributionResponse, GetContributionsResponse, + GetContributionsForSitemapResponse, } from "./types"; @Service() @@ -22,6 +23,16 @@ export class ContributionController { }; } + @Get("/for-sitemap") + public async getContributionsForSitemap(): Promise { + // @TODO-ZM: title is a markdown, we should render it to plain text + const contributions = await this.contributionRepository.findForSitemap(); + + return { + contributions, + }; + } + @Get("/:id") public async getContribution(@Param("id") id: string): Promise { const contribution = await this.contributionRepository.findByIdWithStats(id); diff --git a/api/src/contribution/repository.ts b/api/src/contribution/repository.ts index 3aa7cc3eb..816f9e2e5 100644 --- a/api/src/contribution/repository.ts +++ b/api/src/contribution/repository.ts @@ -79,6 +79,22 @@ export class ContributionRepository { return camelCased; } + public async findForSitemap() { + const statement = sql` + SELECT + ${contributionsTable.id}, + ${contributionsTable.title} + FROM + ${contributionsTable} + `; + + const raw = await this.postgresService.db.execute(statement); + const entries = Array.from(raw); + const unStringifiedRaw = unStringifyDeep(entries); + const camelCased = camelCaseObject(unStringifiedRaw); + return camelCased; + } + public async upsert(contribution: ContributionRow) { return await this.postgresService.db .insert(contributionsTable) diff --git a/api/src/contribution/types.ts b/api/src/contribution/types.ts index d57f04efd..093b6b968 100644 --- a/api/src/contribution/types.ts +++ b/api/src/contribution/types.ts @@ -30,3 +30,7 @@ export interface GetContributionResponse extends GeneralResponse { export interface GetContributionTitleResponse extends GeneralResponse { contribution: Pick; } + +export interface GetContributionsForSitemapResponse extends GeneralResponse { + contributions: Array>; +} diff --git a/web/cloudflare/functions/w/contributions-sitemap.xml.ts b/web/cloudflare/functions/w/contributions-sitemap.xml.ts new file mode 100644 index 000000000..42b9f970e --- /dev/null +++ b/web/cloudflare/functions/w/contributions-sitemap.xml.ts @@ -0,0 +1,49 @@ +import { Env } from "handler/contribution"; +import { environments } from "@dzcode.io/utils/dist/config/environment"; +import { allLanguages, LanguageEntity } from "@dzcode.io/models/dist/language"; +import { getContributionURL } from "@dzcode.io/web/dist/utils/contribution"; +import { fsConfig } from "@dzcode.io/utils/dist/config"; +import { fetchV2Factory } from "@dzcode.io/utils/dist/fetch/factory"; +import { Endpoints } from "@dzcode.io/api/dist/app/endpoints"; + +export const onRequest: PagesFunction = async (context) => { + let stage = context.env.STAGE; + if (!environments.includes(stage)) { + console.log(`⚠️ No STAGE provided, falling back to "development"`); + stage = "development"; + } + const fullstackConfig = fsConfig(stage); + const fetchV2 = fetchV2Factory(fullstackConfig); + + const { contributions } = await fetchV2("api:contributions/for-sitemap", {}); + + const hostname = "https://www.dzCode.io"; + const links = contributions.reduce<{ url: string; lang: LanguageEntity["code"] }[]>((pV, cV) => { + return [ + ...pV, + ...allLanguages.map(({ baseUrl, code }) => ({ + url: `${baseUrl}${getContributionURL(cV)}`, + lang: code, + })), + ]; + }, []); + + const xml = ` + + ${links + .map( + (link) => ` + + ${hostname}${link.url} + + `, + ) + .join("")} +`; + + return new Response(xml, { headers: { "content-type": "application/xml; charset=utf-8" } }); +}; From 8f603bd81c6c6062cf66402adecddbb87d4b0b0a Mon Sep 17 00:00:00 2001 From: Zakaria Mansouri Date: Sun, 29 Dec 2024 23:11:09 +0100 Subject: [PATCH 7/9] feat: add XML escaping for contribution URLs in sitemap --- web/cloudflare/functions/w/contributions-sitemap.xml.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/web/cloudflare/functions/w/contributions-sitemap.xml.ts b/web/cloudflare/functions/w/contributions-sitemap.xml.ts index 42b9f970e..0c84078b4 100644 --- a/web/cloudflare/functions/w/contributions-sitemap.xml.ts +++ b/web/cloudflare/functions/w/contributions-sitemap.xml.ts @@ -6,6 +6,13 @@ import { fsConfig } from "@dzcode.io/utils/dist/config"; import { fetchV2Factory } from "@dzcode.io/utils/dist/fetch/factory"; import { Endpoints } from "@dzcode.io/api/dist/app/endpoints"; +function xmlEscape(s: string) { + return s.replace( + /[<>&"']/g, + (c) => ({ "<": "<", ">": ">", "&": "&", '"': """, "'": "'" })[c] as string, + ); +} + export const onRequest: PagesFunction = async (context) => { let stage = context.env.STAGE; if (!environments.includes(stage)) { @@ -22,7 +29,7 @@ export const onRequest: PagesFunction = async (context) => { return [ ...pV, ...allLanguages.map(({ baseUrl, code }) => ({ - url: `${baseUrl}${getContributionURL(cV)}`, + url: xmlEscape(`${baseUrl}${getContributionURL(cV)}`), lang: code, })), ]; From de7f69ae70f50b7b9708c273445b5466a67b5611 Mon Sep 17 00:00:00 2001 From: Zakaria Mansouri Date: Mon, 30 Dec 2024 12:54:49 +0100 Subject: [PATCH 8/9] refactor: remove TODO comment regarding title rendering in sitemap contributions --- api/src/contribution/controller.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/api/src/contribution/controller.ts b/api/src/contribution/controller.ts index 9e8e3b133..1db22f444 100644 --- a/api/src/contribution/controller.ts +++ b/api/src/contribution/controller.ts @@ -25,7 +25,6 @@ export class ContributionController { @Get("/for-sitemap") public async getContributionsForSitemap(): Promise { - // @TODO-ZM: title is a markdown, we should render it to plain text const contributions = await this.contributionRepository.findForSitemap(); return { From fcc1eb5cdb178d47dd6513637b6c23ce4f5500ff Mon Sep 17 00:00:00 2001 From: Zakaria Mansouri Date: Mon, 30 Dec 2024 12:57:08 +0100 Subject: [PATCH 9/9] refactor: add TODO for SQL injection guard in findTitle method --- api/src/contribution/repository.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/api/src/contribution/repository.ts b/api/src/contribution/repository.ts index 816f9e2e5..598b49315 100644 --- a/api/src/contribution/repository.ts +++ b/api/src/contribution/repository.ts @@ -15,6 +15,7 @@ export class ContributionRepository { constructor(private readonly postgresService: PostgresService) {} public async findTitle(contributionId: string) { + // todo-ZM: guard against SQL injections in all sql`` statements const statement = sql` SELECT ${contributionsTable.title}