Skip to content
This repository has been archived by the owner on Aug 13, 2024. It is now read-only.

Commit

Permalink
feat: higlight card upload & storage (#39)
Browse files Browse the repository at this point in the history
* feat: higlight card upload & storage

* feat: added parse int pipe

* parseIntPipe

* type fixes

* feat: check if highlight needs regeneration

* lint
  • Loading branch information
nightknighto committed May 4, 2023
1 parent e61f754 commit b6abefa
Show file tree
Hide file tree
Showing 6 changed files with 164 additions and 9 deletions.
21 changes: 21 additions & 0 deletions src/s3-file-storage/s3-file-storage.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,27 @@ export class S3FileStorageService {
}
}

async getFileMeta (hash: string): Promise<Record<string, string> | 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,
Expand Down
71 changes: 71 additions & 0 deletions src/social-card/highlight-card/highlight-card.controller.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void> {
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();
}
}
2 changes: 2 additions & 0 deletions src/social-card/highlight-card/highlight-card.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
64 changes: 62 additions & 2 deletions src/social-card/highlight-card/highlight-card.service.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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,
Expand All @@ -22,6 +23,7 @@ interface HighlightCardData {
langs: (Language & {
size: number,
})[],
updated_at: Date
}

@Injectable()
Expand All @@ -42,7 +44,7 @@ export class HighlightCardService {
const today30daysAgo = new Date((new Date).setDate(today.getDate() - 30));

const highlightReq = await firstValueFrom(this.httpService.get<DbHighlight>(`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<DbReaction[]>(`https://api.opensauced.pizza/v1/highlights/${highlightId}/reactions`));
const reactions = reactionsReq.data.reduce<number>( (acc, curr) => acc + Number(curr.reaction_count), 0);
Expand Down Expand Up @@ -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),
};
}

Expand Down Expand Up @@ -110,4 +113,61 @@ export class HighlightCardService {

return { png: pngData.asPng(), svg };
}

async checkRequiresUpdate (id: number): Promise<RequiresUpdateMeta> {
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<string> {
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);
}
}
}
8 changes: 1 addition & 7 deletions src/social-card/user-card/user-card.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
7 changes: 7 additions & 0 deletions typings/RequiresUpdateMeta.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@

export default interface RequiresUpdateMeta {
fileUrl: string,
hasFile: boolean;
needsUpdate: boolean;
lastModified: Date | null,
}

0 comments on commit b6abefa

Please sign in to comment.