diff --git a/CHANGELOG.md b/CHANGELOG.md index 8100cff..7f9b553 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,48 @@ > All notable changes to this project will be documented in this file +## [2.1.0-beta.6](https://github.com/open-sauced/opengraph.opensauced.pizza/compare/v2.1.0-beta.5...v2.1.0-beta.6) (2023-05-09) + + +### 🍕 Features + +* UI adjustments ([#42](https://github.com/open-sauced/opengraph.opensauced.pizza/issues/42)) ([acbe81a](https://github.com/open-sauced/opengraph.opensauced.pizza/commit/acbe81a39303d7495fd4bebd51306bd3fe8d9d18)) + +## [2.1.0-beta.5](https://github.com/open-sauced/opengraph.opensauced.pizza/compare/v2.1.0-beta.4...v2.1.0-beta.5) (2023-05-05) + + +### 🍕 Features + +* highlights getting repo name & languages from pr's repo ([#41](https://github.com/open-sauced/opengraph.opensauced.pizza/issues/41)) ([1555a25](https://github.com/open-sauced/opengraph.opensauced.pizza/commit/1555a25a114b0d812c058d1a9728706a63965b44)) + +## [2.1.0-beta.4](https://github.com/open-sauced/opengraph.opensauced.pizza/compare/v2.1.0-beta.3...v2.1.0-beta.4) (2023-05-04) + + +### 🍕 Features + +* higlight card upload & storage ([#39](https://github.com/open-sauced/opengraph.opensauced.pizza/issues/39)) ([b6abefa](https://github.com/open-sauced/opengraph.opensauced.pizza/commit/b6abefaa364c5c7c3f97e52990808ad6687e989c)) + +## [2.1.0-beta.3](https://github.com/open-sauced/opengraph.opensauced.pizza/compare/v2.1.0-beta.2...v2.1.0-beta.3) (2023-05-02) + + +### 🍕 Features + +* Highlight Cards UI Generation (frontend-only) ([#36](https://github.com/open-sauced/opengraph.opensauced.pizza/issues/36)) ([138a847](https://github.com/open-sauced/opengraph.opensauced.pizza/commit/138a847bad92eb1f35fafa467720de01646f31cc)) + +## [2.1.0-beta.2](https://github.com/open-sauced/opengraph.opensauced.pizza/compare/v2.1.0-beta.1...v2.1.0-beta.2) (2023-04-30) + + +### 🐛 Bug Fixes + +* Overflowing language bar fix ([#38](https://github.com/open-sauced/opengraph.opensauced.pizza/issues/38)) ([3dc1a0a](https://github.com/open-sauced/opengraph.opensauced.pizza/commit/3dc1a0add116c1124af6a993e9e843015f3c0d25)) + +## [2.1.0-beta.1](https://github.com/open-sauced/opengraph.opensauced.pizza/compare/v2.0.1...v2.1.0-beta.1) (2023-04-26) + + +### 🍕 Features + +* adding utilities for cards local generation & testing ([#34](https://github.com/open-sauced/opengraph.opensauced.pizza/issues/34)) ([c5a5fec](https://github.com/open-sauced/opengraph.opensauced.pizza/commit/c5a5fecd3689b11fd3325c7f0fd3228bd368e0f5)) + ### [2.0.1](https://github.com/open-sauced/opengraph.opensauced.pizza/compare/v2.0.0...v2.0.1) (2023-04-19) diff --git a/README.md b/README.md index f9b0e6a..8ece0ef 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,18 @@ To start a local copy of the app on port `3001`: npm run start:dev ``` +### Local dev scripts + +There are a few scripts that can be used to generate and test the social cards locally without having to deploy to the CDN. This is the way to go when developing & testing the interface for the social cards. + +#### Generating user profile cards: + +```shell +npm run local-dev:usercards +``` + +> Generates user cards for all users in the test array inside `test/local-dev/UserCards.ts` and outputs them in `dist/local-dev/` for testing. + ### 📝 Environment variables Some environment variables are required to run the application. You can find them in the `.env.example` file. While most of them are optional, some are required to run the application. diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index ee5a535..8b7f894 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -1,12 +1,12 @@ { "name": "@open-sauced/opengraph.opensauced.pizza", - "version": "2.0.1", + "version": "2.1.0-beta.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@open-sauced/opengraph.opensauced.pizza", - "version": "2.0.1", + "version": "2.1.0-beta.6", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index d91ee86..f9e53b3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@open-sauced/opengraph.opensauced.pizza", - "version": "2.0.1", + "version": "2.1.0-beta.6", "keywords": [], "description": "OpenGraph dot Open Sauced is a general purpose social card generator", "author": "Ahmed Mohamed Atwa ", @@ -38,6 +38,8 @@ "test:cov": "npm run test --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:e2e": "npm run test --config test/jest-e2e.json", + "test:local:user": "npx ts-node test/local-dev/UserCards", + "test:local:highlight": "npx ts-node test/local-dev/HighlightCards", "docs": "npx compodoc -p tsconfig.json --hideGenerator --disableDependencies -d ./dist/documentation ./src", "docs:serve": "npm run docs -- --serve" }, diff --git a/public/diagram.svg b/public/diagram.svg index de51b55..201a8d0 100644 --- a/public/diagram.svg +++ b/public/diagram.svg @@ -1 +1 @@ -social-cardsocial-cards3-file-storages3-file-storagehealthhealthgithubgithubconfigconfigtemplatestemplatessocial-card.service.tssocial-card.service.tssocial-card.service.tssocial-card.controll...social-card.controll...social-card.controll...social-car...social-car...social-car...s3-file-storage.serv...s3-file-storage.serv...s3-file-storage.serv...health.controller.tshealth.controller.tshealth.controller.tsgql/get-user.tsgql/get-user.tsgql/get-user.tsgithub.service.tsgithub.service.tsgithub.service.tsapi.config.tsapi.config.tsapi.config.tsdigital-oce...digital-oce...digital-oce...github.c...github.c...github.c...main.tsmain.tsmain.tsapp.module.tsapp.module.tsapp.module.tstailwind.config.tstailwind.config.tstailwind.config.tsuser-profile-c...user-profile-c...user-profile-c...user-langs.tsuser-langs.tsuser-langs.tsuser-profil...user-profil...user-profil...repo-icon-...repo-icon-...repo-icon-....tseach dot sized by file size \ No newline at end of file +social-cardsocial-cards3-file-storages3-file-storagehealthhealthgithubgithubconfigconfiguser-carduser-cardtemplatestemplateshighlight-cardhighlight-cardgqlgqlsharedshareds3-file-storage.s...s3-file-storage.s...s3-file-storage.s...health.controll...health.controll...health.controll...github.servic...github.servic...github.servic...api.config.tsapi.config.tsapi.config.tsdigital-o...digital-o...digital-o...main.tsmain.tsmain.tsapp.module.tsapp.module.tsapp.module.tsuser-card.service.tsuser-card.service.tsuser-card.service.tsuser-card.contro...user-card.contro...user-card.contro...user-card...user-card...user-card...tailwind.config.tstailwind.config.tstailwind.config.tshighlight-ca...highlight-ca...highlight-ca...user-profi...user-profi...user-profi...highlight-card.service.tshighlight-card.service.tshighlight-card.service.tshighlight-card.c...highlight-card.c...highlight-card.c...highlight...highlight...highlight...get-user.tsget-user.tsget-user.tsget-repo.tsget-repo.tsget-repo.tscard-footer.tscard-footer.tscard-footer.tsuser-repos.tsuser-repos.tsuser-repos.tsuser-lang...user-lang...user-lang...repo-ico...repo-ico...repo-ico....gql.tseach dot sized by file size \ No newline at end of file diff --git a/src/app.module.ts b/src/app.module.ts index 6b2121c..e363c03 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -8,8 +8,9 @@ import { clc } from "@nestjs/common/utils/cli-colors.util"; import ApiConfig from "./config/api.config"; import GitHubConfig from "./config/github.config"; import DigitalOceanConfig from "./config/digital-ocean.config"; -import { SocialCardModule } from "./social-card/social-card.module"; +import { UserCardModule } from "./social-card/user-card/user-card.module"; import { S3FileStorageModule } from "./s3-file-storage/s3-file-storage.module"; +import { HighlightCardModule } from "./social-card/highlight-card/highlight-card.module"; @Module({ imports: [ @@ -46,8 +47,9 @@ import { S3FileStorageModule } from "./s3-file-storage/s3-file-storage.module"; }), TerminusModule, HttpModule, - SocialCardModule, S3FileStorageModule, + UserCardModule, + HighlightCardModule, ], controllers: [], providers: [], diff --git a/src/github/github.service.ts b/src/github/github.service.ts index 88087e7..ed15f36 100644 --- a/src/github/github.service.ts +++ b/src/github/github.service.ts @@ -1,9 +1,10 @@ import { Inject, Injectable, Logger } from "@nestjs/common"; import { ConfigType } from "@nestjs/config"; import { graphql } from "@octokit/graphql"; -import { RateLimit, User } from "@octokit/graphql-schema"; +import { RateLimit, Repository, User } from "@octokit/graphql-schema"; import GithubConfig from "../config/github.config"; +import getRepo from "./gql/get-repo"; import getUser from "./gql/get-user"; @Injectable() @@ -29,6 +30,14 @@ export class GithubService { return user; } + async getRepo (owner: string, repo: string) { + const { query, variables } = getRepo(owner, repo); + + const { repository } = await this.graphqlWithAuth<{ repository: Repository }>(query, variables); + + return repository; + } + async rateLimit () { const { rateLimit } = await this.graphqlWithAuth<{ rateLimit: RateLimit }>(`query { rateLimit { diff --git a/src/github/gql/get-repo.ts b/src/github/gql/get-repo.ts new file mode 100644 index 0000000..7c9a60c --- /dev/null +++ b/src/github/gql/get-repo.ts @@ -0,0 +1,37 @@ +const getRepo = (owner: string, repo: string) => ({ + query: ` +query ($owner: String!, $repo: String!) { + repository( + owner: $owner + name: $repo + ) { + id + name + databaseId + nameWithOwner + owner { + id + login + avatarUrl + } + languages (first: 100, orderBy: { field: SIZE, direction: DESC }) { + edges { + node { + id + color + name + } + size + } + totalSize + totalCount + } + } +}`, + variables: { + owner, + repo, + }, +}); + +export default getRepo; diff --git a/src/s3-file-storage/s3-file-storage.service.ts b/src/s3-file-storage/s3-file-storage.service.ts index 7bb47ec..6ee89ad 100644 --- a/src/s3-file-storage/s3-file-storage.service.ts +++ b/src/s3-file-storage/s3-file-storage.service.ts @@ -71,6 +71,27 @@ export class S3FileStorageService { } } + async getFileMeta (hash: string): Promise | null> { + try { + const response = await this.s3Client.send( + new HeadObjectCommand({ + Bucket: this.config.bucketName, + Key: hash, + }), + ); + + return response.Metadata ?? null; + } catch (error) { + if (error instanceof Error) { + if (error.name === "NotFound") { + return null; + } + } + + throw error; + } + } + async uploadFile ( fileContent: Buffer | Readable, hash: string, diff --git a/src/social-card/highlight-card/highlight-card.controller.ts b/src/social-card/highlight-card/highlight-card.controller.ts new file mode 100644 index 0000000..8cc2a6b --- /dev/null +++ b/src/social-card/highlight-card/highlight-card.controller.ts @@ -0,0 +1,71 @@ +import { Controller, Get, Header, HttpStatus, Param, ParseIntPipe, Redirect, Res, StreamableFile } from "@nestjs/common"; +import { + ApiBadRequestResponse, + ApiForbiddenResponse, + ApiNoContentResponse, + ApiNotFoundResponse, + ApiOkResponse, + ApiOperation, ApiResponse, + ApiTags, +} from "@nestjs/swagger"; +import { FastifyReply } from "fastify"; +import { HighlightCardService } from "./highlight-card.service"; + + +@Controller("highlights") +@ApiTags("Highlight social cards") +export class HighlightCardController { + constructor ( + private readonly highlightCardService: HighlightCardService, + ) {} + + @Get("/:id") + @ApiOperation({ + operationId: "generateHighlightSocialCard", + summary: "Gets latest cache aware social card link for :id or generates a new one", + }) + @Header("Content-Type", "image/png") + @ApiOkResponse({ type: StreamableFile, description: "Social card image" }) + @ApiNotFoundResponse({ description: "Highlight not found" }) + @ApiForbiddenResponse({ description: "Rate limit exceeded" }) + @ApiBadRequestResponse({ description: "Invalid highlight id" }) + @Redirect() + async generateHighlightSocialCard ( + @Param("id", ParseIntPipe) id: number, + @Res({ passthrough: true }) res: FastifyReply, + ): Promise { + const { fileUrl, hasFile, needsUpdate } = await this.highlightCardService.checkRequiresUpdate(id); + + if (hasFile && !needsUpdate) { + return res.status(HttpStatus.FOUND).redirect(fileUrl); + } + + const url = await this.highlightCardService.getHighlightCard(id); + + return res.status(HttpStatus.FOUND).redirect(url); + } + + @Get("/:id/metadata") + @ApiOperation({ + operationId: "getHighlightSocialCardMetadata", + summary: "Gets latest cache aware social card metadata for :id", + }) + @ApiNoContentResponse({ description: "Highlight social card image is up to date", status: HttpStatus.NO_CONTENT }) + @ApiResponse({ description: "Highlight social card image needs regeneration", status: HttpStatus.NOT_MODIFIED }) + @ApiNotFoundResponse({ description: "Highlight social card image not found", status: HttpStatus.NOT_FOUND }) + @ApiBadRequestResponse({ description: "Invalid highlight id", status: HttpStatus.BAD_REQUEST }) + async checkHighlightSocialCard ( + @Param("id", ParseIntPipe) id: number, + @Res({ passthrough: true }) res: FastifyReply, + ): Promise { + const { fileUrl, hasFile, needsUpdate, lastModified } = await this.highlightCardService.checkRequiresUpdate(id); + + return res + .headers({ + "x-amz-meta-last-modified": lastModified?.toISOString() ?? "", + "x-amz-meta-location": fileUrl, + }) + .status(hasFile ? needsUpdate ? HttpStatus.NOT_MODIFIED : HttpStatus.NO_CONTENT : HttpStatus.NOT_FOUND) + .send(); + } +} diff --git a/src/social-card/highlight-card/highlight-card.module.ts b/src/social-card/highlight-card/highlight-card.module.ts new file mode 100644 index 0000000..6d42e1a --- /dev/null +++ b/src/social-card/highlight-card/highlight-card.module.ts @@ -0,0 +1,13 @@ +import { Module } from "@nestjs/common"; +import { HttpModule } from "@nestjs/axios"; +import { GithubModule } from "../../github/github.module"; +import { S3FileStorageModule } from "../../s3-file-storage/s3-file-storage.module"; +import { HighlightCardService } from "../highlight-card/highlight-card.service"; +import { HighlightCardController } from "./highlight-card.controller"; + +@Module({ + imports: [HttpModule, GithubModule, S3FileStorageModule], + providers: [HighlightCardService], + controllers: [HighlightCardController], +}) +export class HighlightCardModule {} diff --git a/src/social-card/highlight-card/highlight-card.service.ts b/src/social-card/highlight-card/highlight-card.service.ts new file mode 100644 index 0000000..e0f6667 --- /dev/null +++ b/src/social-card/highlight-card/highlight-card.service.ts @@ -0,0 +1,162 @@ +import { ForbiddenException, Injectable, Logger, NotFoundException } from "@nestjs/common"; +import { HttpService } from "@nestjs/axios"; +import { Resvg } from "@resvg/resvg-js"; +import { Repository, Language } from "@octokit/graphql-schema"; +import fs from "node:fs/promises"; + +import { GithubService } from "../../github/github.service"; +import { S3FileStorageService } from "../../s3-file-storage/s3-file-storage.service"; +import userLangs from "../templates/shared/user-langs"; +import userProfileRepos from "../templates/shared/user-repos"; +import tailwindConfig from "../templates/tailwind.config"; +import { firstValueFrom } from "rxjs"; +import highlightCardTemplate from "../templates/highlight-card.template"; +import RequiresUpdateMeta from "../../../typings/RequiresUpdateMeta"; + +interface HighlightCardData { + title: string, + body: string, + reactions: number, + avatarUrl: string, + repo: Repository, + langTotal: number, + langs: (Language & { + size: number, + })[], + updated_at: Date, + url: string, +} + +@Injectable() +export class HighlightCardService { + private readonly logger = new Logger(this.constructor.name); + + constructor ( + private readonly httpService: HttpService, + private readonly githubService: GithubService, + private readonly s3FileStorageService: S3FileStorageService, + ) {} + + private async getHighlightData (highlightId: number): Promise { + const highlightReq = await firstValueFrom(this.httpService.get(`https://api.opensauced.pizza/v1/user/highlights/${highlightId}`)); + const { login, title, highlight: body, updated_at, url } = highlightReq.data; + + const reactionsReq = await firstValueFrom(this.httpService.get(`https://api.opensauced.pizza/v1/highlights/${highlightId}/reactions`)); + const reactions = reactionsReq.data.reduce( (acc, curr) => acc + Number(curr.reaction_count), 0); + + const [owner, repoName] = url.replace("https://github.com/", "").split("/"); + + const user = await this.githubService.getUser(login); + const repo = await this.githubService.getRepo(owner, repoName); + + const langList = repo.languages?.edges?.flatMap(edge => { + if (edge) { + return { + ...edge.node, + size: edge.size, + }; + } + }) as (Language & { size: number })[]; + + return { + title, + body, + reactions, + avatarUrl: `${String(user.avatarUrl)}&size=150`, + langs: langList, + langTotal: repo.languages?.totalSize ?? 0, + repo, + updated_at: new Date(updated_at), + url, + }; + } + + // public only to be used in local scripts. Not for controller direct use. + async generateCardBuffer (highlightId: number, highlightData?: HighlightCardData) { + const { html } = await import("satori-html"); + const satori = (await import("satori")).default; + + const { title, body, reactions, avatarUrl, repo, langs, langTotal } = highlightData ? highlightData : await this.getHighlightData(highlightId); + + const template = html(highlightCardTemplate(avatarUrl, title, body, userLangs(langs, langTotal), userProfileRepos([repo], 2), reactions)); + + const interArrayBuffer = await fs.readFile("node_modules/@fontsource/inter/files/inter-all-400-normal.woff"); + + const svg = await satori(template, { + width: 1200, + height: 627, + fonts: [ + { + name: "Inter", + data: interArrayBuffer, + weight: 400, + style: "normal", + }, + ], + tailwindConfig, + }); + + const resvg = new Resvg(svg, { background: "rgba(238, 235, 230, .9)" }); + + const pngData = resvg.render(); + + return { png: pngData.asPng(), svg }; + } + + async checkRequiresUpdate (id: number): Promise { + const hash = `highlights/${String(id)}.png`; + const fileUrl = `${this.s3FileStorageService.getCdnEndpoint()}${hash}`; + const hasFile = await this.s3FileStorageService.fileExists(hash); + + const returnVal: RequiresUpdateMeta = { + fileUrl, + hasFile, + needsUpdate: true, + lastModified: null, + }; + + if (hasFile) { + const lastModified = await this.s3FileStorageService.getFileLastModified(hash); + + returnVal.lastModified = lastModified; + + const { updated_at, reactions } = await this.getHighlightData(id); + const metadata = await this.s3FileStorageService.getFileMeta(hash); + const savedReactions = metadata?.["reactions-count"] ?? "0"; + + if (lastModified && lastModified > updated_at && savedReactions === String(reactions)) { + this.logger.debug(`Highlight ${id} exists in S3 with lastModified: ${lastModified.toISOString()} newer than updated_at: ${updated_at.toISOString()}, and reaction count is the same, redirecting to ${fileUrl}`); + returnVal.needsUpdate = false; + } + } + + return returnVal; + } + + async getHighlightCard (id: number): Promise { + const { remaining } = await this.githubService.rateLimit(); + + if (remaining < 1000) { + throw new ForbiddenException("Rate limit exceeded"); + } + + const highlightData = await this.getHighlightData(id); + + try { + const hash = `highlights/${String(id)}.png`; + const fileUrl = `${this.s3FileStorageService.getCdnEndpoint()}${hash}`; + + const { png } = await this.generateCardBuffer(id, highlightData); + + await this.s3FileStorageService.uploadFile(png, hash, "image/png", { "reactions-count": String(highlightData.reactions) }); + + this.logger.debug(`Highlight ${id} did not exist in S3, generated image and uploaded to S3, redirecting`); + + return fileUrl; + } catch (e) { + this.logger.error(`Error generating highlight card for ${id}`, e); + + throw (new NotFoundException); + } + } +} diff --git a/src/social-card/social-card.module.ts b/src/social-card/social-card.module.ts deleted file mode 100644 index bef529e..0000000 --- a/src/social-card/social-card.module.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Module } from "@nestjs/common"; -import { HttpModule } from "@nestjs/axios"; - -import { SocialCardService } from "./social-card.service"; -import { SocialCardController } from "./social-card.controller"; -import { GithubModule } from "../github/github.module"; -import { S3FileStorageModule } from "../s3-file-storage/s3-file-storage.module"; - -@Module({ - imports: [HttpModule, GithubModule, S3FileStorageModule], - providers: [SocialCardService], - controllers: [SocialCardController], -}) -export class SocialCardModule {} diff --git a/src/social-card/templates/highlight-card.template.ts b/src/social-card/templates/highlight-card.template.ts new file mode 100644 index 0000000..15a2eda --- /dev/null +++ b/src/social-card/templates/highlight-card.template.ts @@ -0,0 +1,30 @@ +import cardFooter from "./shared/card-footer"; +import cardStyleSetup from "./shared/card-style-setup"; + +const highlightCardTemplate = (avatarUrl: string, title: string, body: string, langs: string, repos: string, reactions: number): string => ` + ${cardStyleSetup} + +
+
+
+ +
+ +
+

+ ${title} +

+

+ ${body.length > 108 ? `${body.slice(0, 108)}...` : body} +

+
+ +
+ +
+
+ + ${cardFooter(langs, repos, reactions)} +
`; + +export default highlightCardTemplate; diff --git a/src/social-card/templates/shared/card-footer.ts b/src/social-card/templates/shared/card-footer.ts new file mode 100644 index 0000000..caa6fcd --- /dev/null +++ b/src/social-card/templates/shared/card-footer.ts @@ -0,0 +1,31 @@ + +const heartIconData = `data:image/svg+xml,%3csvg width='24' height='24' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'%3e%3cpath fill-rule='evenodd' clip-rule='evenodd' d='M6.73649 2.5C3.82903 2.5 1 5.052 1 8.51351C1 12.3318 3.80141 15.5735 6.38882 17.7763C7.70549 18.8973 9.01844 19.7929 10.0004 20.4077C10.4922 20.7157 10.9029 20.9544 11.1922 21.1169C11.4093 21.2388 11.5582 21.318 11.6223 21.3516C11.7407 21.4132 11.8652 21.4527 12 21.4527C12.1193 21.4527 12.2378 21.4238 12.3438 21.3693C12.5003 21.2886 12.6543 21.2031 12.8078 21.1169C13.0971 20.9544 13.5078 20.7157 13.9996 20.4077C14.9816 19.7929 16.2945 18.8973 17.6112 17.7763C20.1986 15.5735 23 12.3318 23 8.51351C23 5.052 20.171 2.5 17.2635 2.5C14.9702 2.5 13.1192 3.72621 12 5.60482C10.8808 3.72621 9.02981 2.5 6.73649 2.5ZM6.73649 4C4.65746 4 2.5 5.88043 2.5 8.51351C2.5 11.6209 4.8236 14.4738 7.36118 16.6342C8.60701 17.6948 9.85656 18.5479 10.7965 19.1364C11.2656 19.4301 11.6557 19.6567 11.9269 19.8091L12 19.85L12.0731 19.8091C12.3443 19.6567 12.7344 19.4301 13.2035 19.1364C14.1434 18.5479 15.393 17.6948 16.6388 16.6342C19.1764 14.4738 21.5 11.6209 21.5 8.51351C21.5 5.88043 19.3425 4 17.2635 4C15.1581 4 13.4627 5.38899 12.7115 7.64258C12.6094 7.94883 12.3228 8.15541 12 8.15541C11.6772 8.15541 11.3906 7.94883 11.2885 7.64258C10.5373 5.38899 8.84185 4 6.73649 4Z' fill='%2324292F'/%3e%3c/svg%3e`; + +const cardFooter = (langs: string, repos: string, reactions?: number) => ` +
+
+
+ ${repos} +
+ + ${reactions + ? ` +
+ + + ${reactions} Reactions + +
+ ` + : ""} +
+ +
+
+ ${langs} +
+
+
+ `; + +export default cardFooter; diff --git a/src/social-card/templates/shared/card-style-setup.ts b/src/social-card/templates/shared/card-style-setup.ts new file mode 100644 index 0000000..f3b633d --- /dev/null +++ b/src/social-card/templates/shared/card-style-setup.ts @@ -0,0 +1,10 @@ + +const cardStyleSetup = ` + +`; + +export default cardStyleSetup; diff --git a/src/social-card/templates/repo-icon-with-name.ts b/src/social-card/templates/shared/repo-icon-with-name.ts similarity index 100% rename from src/social-card/templates/repo-icon-with-name.ts rename to src/social-card/templates/shared/repo-icon-with-name.ts diff --git a/src/social-card/templates/user-langs.ts b/src/social-card/templates/shared/user-langs.ts similarity index 76% rename from src/social-card/templates/user-langs.ts rename to src/social-card/templates/shared/user-langs.ts index 09bebda..17d8e77 100644 --- a/src/social-card/templates/user-langs.ts +++ b/src/social-card/templates/shared/user-langs.ts @@ -2,11 +2,11 @@ import { Language } from "@octokit/graphql-schema"; const userLangs = (langs: (Language & { size: number })[], totalCount = 0, joinLiteral = "") => { const filteredLangs = langs - .filter(({ size }) => size > 0 && !((size / totalCount) * 100 < 0.5)); + .filter(({ size }) => size > 0 && (size / totalCount) * 100 >= 0.5); return filteredLangs .map(({ color, size }) => { - const realPercent = Math.round(size / totalCount * 100); + const realPercent = size / totalCount * 100; return `
`; }) diff --git a/src/social-card/templates/shared/user-repos.ts b/src/social-card/templates/shared/user-repos.ts new file mode 100644 index 0000000..faedf7d --- /dev/null +++ b/src/social-card/templates/shared/user-repos.ts @@ -0,0 +1,14 @@ +import repoIconWithName from "./repo-icon-with-name"; +import { Repository } from "@octokit/graphql-schema"; + +const userProfileRepos = (repos: Repository[], limit: number): string => { + const charLimit = limit === 1 ? 60 : repos.length === 1 ? 60 : 15; + const repoList = repos.map(({ name, owner: { avatarUrl } }) => + repoIconWithName(`${name.substring(0, charLimit).replace(/\.+$/, "")}${name.length > charLimit ? "..." : ""}`, `${String(avatarUrl)}&size=40`)); + + return `${repoList.slice(0, limit).join("")}${repoList.length > limit + ? `

+${repoList.length - limit}

` + : ``}`; +}; + +export default userProfileRepos; diff --git a/src/social-card/templates/user-profile-card.ts b/src/social-card/templates/user-profile-card.template.ts similarity index 55% rename from src/social-card/templates/user-profile-card.ts rename to src/social-card/templates/user-profile-card.template.ts index 910ab2c..69274ac 100644 --- a/src/social-card/templates/user-profile-card.ts +++ b/src/social-card/templates/user-profile-card.template.ts @@ -1,9 +1,8 @@ -const userProfileCard = (avatarUrl: string, name: string, langs: string, repos: string): string => ` - +import cardFooter from "./shared/card-footer"; +import cardStyleSetup from "./shared/card-style-setup"; + +const userProfileCardTemplate = (avatarUrl: string, name: string, langs: string, repos: string): string => ` + ${cardStyleSetup}
@@ -16,17 +15,7 @@ const userProfileCard = (avatarUrl: string, name: string, langs: string, repos:
-
-
- ${repos} -
- -
-
- ${langs} -
-
-
+ ${cardFooter(langs, repos)}
`; -export default userProfileCard; +export default userProfileCardTemplate; diff --git a/src/social-card/templates/user-profile-repos.ts b/src/social-card/templates/user-profile-repos.ts deleted file mode 100644 index eec1dc7..0000000 --- a/src/social-card/templates/user-profile-repos.ts +++ /dev/null @@ -1,13 +0,0 @@ -import repoIconWithName from "./repo-icon-with-name"; -import { Repository } from "@octokit/graphql-schema"; - -const userProfileRepos = (repos: Repository[]): string => { - const repoList = repos.map(({ name, owner: { avatarUrl } }) => - repoIconWithName(`${name.substring(0, 15).replace(/\.+$/, "")}${name.length > 15 ? "..." : ""}`, `${String(avatarUrl)}&size=40`)); - - return `${repoList.slice(0, 4).join("")}${repoList.length > 4 - ? `

+${repoList.length - 2}

` - : ``}`; -}; - -export default userProfileRepos; diff --git a/src/social-card/social-card.controller.ts b/src/social-card/user-card/user-card.controller.ts similarity index 84% rename from src/social-card/social-card.controller.ts rename to src/social-card/user-card/user-card.controller.ts index 5d8e6d2..4858e5b 100644 --- a/src/social-card/social-card.controller.ts +++ b/src/social-card/user-card/user-card.controller.ts @@ -9,13 +9,13 @@ import { } from "@nestjs/swagger"; import { FastifyReply } from "fastify"; -import { SocialCardService } from "./social-card.service"; +import { UserCardService } from "./user-card.service"; @Controller("users") @ApiTags("User social cards") -export class SocialCardController { +export class UserCardController { constructor ( - private readonly socialCardService: SocialCardService, + private readonly userCardService: UserCardService, ) {} @Get("/:username") @@ -33,13 +33,13 @@ export class SocialCardController { @Res({ passthrough: true }) res: FastifyReply, ): Promise { const sanitizedUsername = username.toLowerCase(); - const { fileUrl, hasFile, needsUpdate } = await this.socialCardService.checkRequiresUpdate(sanitizedUsername); + const { fileUrl, hasFile, needsUpdate } = await this.userCardService.checkRequiresUpdate(sanitizedUsername); if (hasFile && !needsUpdate) { return res.status(HttpStatus.FOUND).redirect(fileUrl); } - const url = await this.socialCardService.getUserCard(sanitizedUsername); + const url = await this.userCardService.getUserCard(sanitizedUsername); return res.status(HttpStatus.FOUND).redirect(url); } @@ -57,7 +57,7 @@ export class SocialCardController { @Res({ passthrough: true }) res: FastifyReply, ): Promise { const sanitizedUsername = username.toLowerCase(); - const { fileUrl, hasFile, needsUpdate, lastModified } = await this.socialCardService.checkRequiresUpdate(sanitizedUsername); + const { fileUrl, hasFile, needsUpdate, lastModified } = await this.userCardService.checkRequiresUpdate(sanitizedUsername); return res .headers({ diff --git a/src/social-card/user-card/user-card.module.ts b/src/social-card/user-card/user-card.module.ts new file mode 100644 index 0000000..5d8e8df --- /dev/null +++ b/src/social-card/user-card/user-card.module.ts @@ -0,0 +1,14 @@ +import { Module } from "@nestjs/common"; +import { HttpModule } from "@nestjs/axios"; + +import { UserCardService } from "./user-card.service"; +import { UserCardController } from "./user-card.controller"; +import { GithubModule } from "../../github/github.module"; +import { S3FileStorageModule } from "../../s3-file-storage/s3-file-storage.module"; + +@Module({ + imports: [HttpModule, GithubModule, S3FileStorageModule], + providers: [UserCardService], + controllers: [UserCardController], +}) +export class UserCardModule {} diff --git a/src/social-card/social-card.service.ts b/src/social-card/user-card/user-card.service.ts similarity index 62% rename from src/social-card/social-card.service.ts rename to src/social-card/user-card/user-card.service.ts index 9fcace5..7d93ea0 100644 --- a/src/social-card/social-card.service.ts +++ b/src/social-card/user-card/user-card.service.ts @@ -2,24 +2,32 @@ import { ForbiddenException, Injectable, Logger, NotFoundException } from "@nest import { HttpService } from "@nestjs/axios"; import { Resvg } from "@resvg/resvg-js"; import { Repository, Language, User } from "@octokit/graphql-schema"; -import { readFile } from "node:fs/promises"; - -import { GithubService } from "../github/github.service"; -import { S3FileStorageService } from "../s3-file-storage/s3-file-storage.service"; -import userLangs from "./templates/user-langs"; -import userProfileRepos from "./templates/user-profile-repos"; -import userProfileCard from "./templates/user-profile-card"; -import tailwindConfig from "./templates/tailwind.config"; - -interface RequiresUpdateMeta { - fileUrl: string, - hasFile: boolean; - needsUpdate: boolean; - lastModified: Date | null, +import fs from "node:fs/promises"; + + +import { GithubService } from "../../github/github.service"; +import { S3FileStorageService } from "../../s3-file-storage/s3-file-storage.service"; +import userLangs from "../templates/shared/user-langs"; +import userProfileRepos from "../templates/shared/user-repos"; +import userProfileCardTemplate from "../templates/user-profile-card.template"; +import tailwindConfig from "../templates/tailwind.config"; +import RequiresUpdateMeta from "../../../typings/RequiresUpdateMeta"; + +interface UserCardData { + id: User["databaseId"], + name: User["name"], + langs: (Language & { + size: number, + })[], + langTotal: number, + repos: Repository[], + avatarUrl: string, + formattedName: string, } + @Injectable() -export class SocialCardService { +export class UserCardService { private readonly logger = new Logger(this.constructor.name); constructor ( @@ -28,16 +36,7 @@ export class SocialCardService { private readonly s3FileStorageService: S3FileStorageService, ) {} - async getUserData (username: string): Promise<{ - id: User["databaseId"], - name: User["name"], - langs: (Language & { - size: number, - })[], - langTotal: number, - repos: Repository[], - avatarUrl: string, - }> { + private async getUserData (username: string): Promise { const langs: Record = {}; @@ -71,9 +70,42 @@ export class SocialCardService { langTotal, repos: user.topRepositories.nodes?.filter(repo => !repo?.isPrivate && repo?.owner.login !== username) as Repository[], avatarUrl: `${String(user.avatarUrl)}&size=150`, + formattedName: user.login ?? username, }; } + // public only to be used in local scripts. Not for controller direct use. + async generateCardBuffer (username: string, userData?: UserCardData) { + const { html } = await import("satori-html"); + const satori = (await import("satori")).default; + + const { avatarUrl, repos, langs, langTotal, formattedName } = userData ? userData : await this.getUserData(username); + + const template = html(userProfileCardTemplate(avatarUrl, formattedName, userLangs(langs, langTotal), userProfileRepos(repos, 3))); + + const interArrayBuffer = await fs.readFile("node_modules/@fontsource/inter/files/inter-all-400-normal.woff"); + + const svg = await satori(template, { + width: 1200, + height: 627, + fonts: [ + { + name: "Inter", + data: interArrayBuffer, + weight: 400, + style: "normal", + }, + ], + tailwindConfig, + }); + + const resvg = new Resvg(svg, { background: "rgba(238, 235, 230, .9)" }); + + const pngData = resvg.render(); + + return { png: pngData.asPng(), svg }; + } + async checkRequiresUpdate (username: string): Promise { const hash = `users/${String(username)}.png`; const fileUrl = `${this.s3FileStorageService.getCdnEndpoint()}${hash}`; @@ -107,39 +139,15 @@ export class SocialCardService { throw new ForbiddenException("Rate limit exceeded"); } - const { html } = await import("satori-html"); - const satori = (await import("satori")).default; + const userData = await this.getUserData(username); try { - const { id, avatarUrl, repos, langs, langTotal } = await this.getUserData(username); const hash = `users/${String(username)}.png`; const fileUrl = `${this.s3FileStorageService.getCdnEndpoint()}${hash}`; - const template = html(userProfileCard(avatarUrl, username, userLangs(langs, langTotal), userProfileRepos(repos))); - - const interArrayBuffer = await readFile("node_modules/@fontsource/inter/files/inter-all-400-normal.woff"); - - const svg = await satori(template, { - width: 1200, - height: 627, - fonts: [ - { - name: "Inter", - data: interArrayBuffer, - weight: 400, - style: "normal", - }, - ], - tailwindConfig, - }); - - const resvg = new Resvg(svg, { background: "rgba(238, 235, 230, .9)" }); - - const pngData = resvg.render(); - - const pngBuffer = pngData.asPng(); + const { png } = await this.generateCardBuffer(username, userData); - await this.s3FileStorageService.uploadFile(pngBuffer, hash, "image/png", { "x-amz-meta-user-id": String(id) }); + await this.s3FileStorageService.uploadFile(png, hash, "image/png", { "x-amz-meta-user-id": String(userData.id) }); this.logger.debug(`User ${username} did not exist in S3, generated image and uploaded to S3, redirecting`); diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts index 50cda62..9ceebc4 100644 --- a/test/app.e2e-spec.ts +++ b/test/app.e2e-spec.ts @@ -1,6 +1,6 @@ import { Test, TestingModule } from '@nestjs/testing'; import { INestApplication } from '@nestjs/common'; -import * as request from 'supertest'; +import request from 'supertest'; import { AppModule } from './../src/app.module'; describe('AppController (e2e)', () => { diff --git a/test/local-dev/HighlightCards.ts b/test/local-dev/HighlightCards.ts new file mode 100644 index 0000000..64d82a7 --- /dev/null +++ b/test/local-dev/HighlightCards.ts @@ -0,0 +1,33 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { AppModule } from "../../src/app.module"; +import { existsSync } from "node:fs"; +import { mkdir, writeFile } from "fs/promises"; +import { HighlightCardService } from "../../src/social-card/highlight-card/highlight-card.service"; + +const testHighlights = [102, 101, 103]; + +const folderPath = "dist"; + +async function testHighlightCards () { + const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule] }).compile(); + + const app = moduleFixture.createNestApplication(); + + await app.init(); + + const instance = app.get(HighlightCardService); + + const promises = testHighlights.map(async id => { + const { svg } = await instance.generateCardBuffer(id); + + if (!existsSync(folderPath)) { + await mkdir(folderPath); + } + await writeFile(`${folderPath}/${id}.svg`, svg); + }); + + // generating sequential: 10.5 seconds, parallel: 4.5 seconds + await Promise.all(promises); +} + +testHighlightCards(); diff --git a/test/local-dev/UserCards.ts b/test/local-dev/UserCards.ts new file mode 100644 index 0000000..fe8aae8 --- /dev/null +++ b/test/local-dev/UserCards.ts @@ -0,0 +1,36 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { AppModule } from "../../src/app.module"; +import { UserCardService } from "../../src/social-card/user-card/user-card.service"; +import { existsSync } from "node:fs"; +import { mkdir, writeFile } from "fs/promises"; + + +const testUsernames = [ + "bdougie", "deadreyo", "defunkt", "0-vortex", "Anush008", "diivi" +]; + +const folderPath = "dist"; + +async function testUserCards () { + const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule] }).compile(); + + const app = moduleFixture.createNestApplication(); + + await app.init(); + + const instance = app.get(UserCardService); + + const promises = testUsernames.map(async username => { + const { svg } = await instance.generateCardBuffer(username); + + if (!existsSync(folderPath)) { + await mkdir(folderPath); + } + await writeFile(`${folderPath}/${username}.svg`, svg); + }); + + // generating sequential: 10.5 seconds, parallel: 4.5 seconds + await Promise.all(promises); +} + +testUserCards(); diff --git a/tsconfig.json b/tsconfig.json index a7f97d6..c14009f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,4 +1,7 @@ { + "ts-node": { + "files": true + }, "compilerOptions": { // "rootDir": "./src", "strict": true, diff --git a/typings/RequiresUpdateMeta.ts b/typings/RequiresUpdateMeta.ts new file mode 100644 index 0000000..1165248 --- /dev/null +++ b/typings/RequiresUpdateMeta.ts @@ -0,0 +1,7 @@ + +export default interface RequiresUpdateMeta { + fileUrl: string, + hasFile: boolean; + needsUpdate: boolean; + lastModified: Date | null, +} diff --git a/typings/global.d.ts b/typings/global.d.ts new file mode 100644 index 0000000..dbd449f --- /dev/null +++ b/typings/global.d.ts @@ -0,0 +1,17 @@ +interface DbHighlight { + readonly id: string; + readonly user_id: string; + readonly url: string; + readonly title: string; + readonly highlight: string; + readonly pinned: boolean; + readonly created_at: string; + readonly updated_at: string; + readonly deleted_at: string | null; + readonly login: string; +} + +interface DbReaction { + emoji_id: string; + reaction_count: string; +}