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

feat: higlight card upload & storage #39

Merged
merged 6 commits into from
May 4, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,
}