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 index 6eb0d70..6d42e1a 100644 --- a/src/social-card/highlight-card/highlight-card.module.ts +++ b/src/social-card/highlight-card/highlight-card.module.ts @@ -3,9 +3,11 @@ 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 index 3c9c586..ed39e13 100644 --- a/src/social-card/highlight-card/highlight-card.service.ts +++ b/src/social-card/highlight-card/highlight-card.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger } from "@nestjs/common"; +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"; @@ -11,6 +11,7 @@ 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, @@ -22,6 +23,7 @@ interface HighlightCardData { langs: (Language & { size: number, })[], + updated_at: Date } @Injectable() @@ -42,7 +44,7 @@ export class HighlightCardService { const today30daysAgo = new Date((new Date).setDate(today.getDate() - 30)); const highlightReq = await firstValueFrom(this.httpService.get(`https://api.opensauced.pizza/v1/user/highlights/${highlightId}`)); - const { login, title, highlight: body } = highlightReq.data; + const { login, title, highlight: body, updated_at } = 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); @@ -76,6 +78,7 @@ export class HighlightCardService { langs: Array.from(Object.values(langs)).sort((a, b) => b.size - a.size), langTotal, repos: user.topRepositories.nodes?.filter(repo => !repo?.isPrivate && repo?.owner.login !== login) as Repository[], + updated_at: new Date(updated_at), }; } @@ -110,4 +113,61 @@ export class HighlightCardService { 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/user-card/user-card.service.ts b/src/social-card/user-card/user-card.service.ts index b64edb3..c730a76 100644 --- a/src/social-card/user-card/user-card.service.ts +++ b/src/social-card/user-card/user-card.service.ts @@ -11,13 +11,7 @@ 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"; - -interface RequiresUpdateMeta { - fileUrl: string, - hasFile: boolean; - needsUpdate: boolean; - lastModified: Date | null, -} +import RequiresUpdateMeta from "../../../typings/RequiresUpdateMeta"; interface UserCardData { id: User["databaseId"], 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, +}