From fdd16d8236742c12dc8fcf4e85136958342b9523 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Thu, 24 Oct 2024 14:19:37 +0200 Subject: [PATCH] Add OG and Twitter Card markup, generate OG profile images closes #2 --- config.ts | 5 ++- deno.json | 4 +- handlers/user-event.ts | 4 +- handlers/user-profile.ts | 2 + html.ts | 63 +++++++++++++++++++++++++----- models/article.ts | 5 +++ models/profile.ts | 10 ++++- server.ts | 17 ++++++-- tasks/generate_og_profile_image.ts | 63 ++++++++++++++++++++++++++++++ utils.ts | 29 ++++++++++++++ 10 files changed, 182 insertions(+), 20 deletions(-) create mode 100644 tasks/generate_og_profile_image.ts create mode 100644 utils.ts diff --git a/config.ts b/config.ts index f553ab3..9e4d73e 100644 --- a/config.ts +++ b/config.ts @@ -1,5 +1,6 @@ import { load } from "@std/dotenv"; import { parse as parseYaml } from "jsr:@std/yaml"; +import { checkIfFileExists } from "./utils.ts"; import { log } from "./log.ts"; const dirname = Deno.cwd(); @@ -15,8 +16,8 @@ const defaultUserConfigPaths = [ ]; for (const path of defaultUserConfigPaths) { - const fileInfo = await Deno.lstat(path).catch((_e) => undefined); - if (fileInfo && fileInfo.isFile) { + const fileExists = await checkIfFileExists(path); + if (fileExists) { userConfigPath = path; break; } diff --git a/deno.json b/deno.json index c8eff02..44c61f2 100644 --- a/deno.json +++ b/deno.json @@ -1,7 +1,7 @@ { "tasks": { - "dev": "deno run --allow-net --allow-read --allow-env --watch server.ts", - "server": "deno run --allow-net --allow-read --allow-env server.ts" + "dev": "deno run --allow-all --watch server.ts", + "server": "deno run --allow-all server.ts" }, "imports": { "@deno/gfm": "jsr:@deno/gfm@^0.9.0", diff --git a/handlers/user-event.ts b/handlers/user-event.ts index 8b581ba..39b5a21 100644 --- a/handlers/user-event.ts +++ b/handlers/user-event.ts @@ -5,6 +5,7 @@ import Article from "../models/article.ts"; import Profile from "../models/profile.ts"; import { articleHtml } from "../html.ts"; import notFoundHandler from "../handlers/not-found.ts"; +import generateOgProfileImage from "../tasks/generate_og_profile_image.ts"; const userEventHandler = async function (ctx: Context) { const username = ctx.state.username.replace(/^(@|~)/, ""); @@ -27,12 +28,13 @@ const userEventHandler = async function (ctx: Context) { const article = new Article(articleEvent); const profile = new Profile(profileEvent, username); const html = articleHtml(article, profile); + generateOgProfileImage(profile); ctx.response.body = html; } else { notFoundHandler(ctx); } - } catch (_e) { + } catch (e) { notFoundHandler(ctx); } }; diff --git a/handlers/user-profile.ts b/handlers/user-profile.ts index b12c743..042bc85 100644 --- a/handlers/user-profile.ts +++ b/handlers/user-profile.ts @@ -5,6 +5,7 @@ import Article from "../models/article.ts"; import Profile from "../models/profile.ts"; import { profilePageHtml } from "../html.ts"; import notFoundHandler from "../handlers/not-found.ts"; +import generateOgProfileImage from "../tasks/generate_og_profile_image.ts"; const userProfileHandler = async function (ctx: Context) { const username = ctx.state.path.replace(/^(@|~)/, ""); @@ -23,6 +24,7 @@ const userProfileHandler = async function (ctx: Context) { const articleEvents = await fetchArticlesByAuthor(pubkey); const articles = articleEvents.map((a) => new Article(a)); const html = profilePageHtml(profile, articles); + generateOgProfileImage(profile); ctx.response.body = html; } else { diff --git a/html.ts b/html.ts index ab9b958..b42738e 100644 --- a/html.ts +++ b/html.ts @@ -2,13 +2,13 @@ import { localizeDate } from "./dates.ts"; import Article from "./models/article.ts"; import Profile from "./models/profile.ts"; -function htmlLayout(title: string, body: string, profile?: Profile): string { - let feedLinksHtml = ""; - if (profile) { - feedLinksHtml = - ``; - } +interface HtmlLayoutOptions { + title: string; + body: string; + metaHtml?: string; +} +function htmlLayout({ title, body, metaHtml }: HtmlLayoutOptions): string { return ` @@ -17,7 +17,7 @@ function htmlLayout(title: string, body: string, profile?: Profile): string { ${title} - ${feedLinksHtml} + ${metaHtml || ""} @@ -35,7 +35,7 @@ export function errorPageHtml(statusCode: number, title: string): string { `; - return htmlLayout(title, body); + return htmlLayout({ title, body }); } export function articleHtml(article: Article, profile: Profile): string { @@ -68,7 +68,10 @@ export function articleHtml(article: Article, profile: Profile): string { `; - return htmlLayout(pageTitle, body, profile); + let metaHtml = articleMetaHtml(article, profile); + metaHtml += feedLinksHtml(profile); + + return htmlLayout({ title: pageTitle, body, metaHtml }); } function articleListItemHtml(article: Article): string { @@ -127,7 +130,10 @@ export function profilePageHtml(profile: Profile, articles: Article[]): string { `; - return htmlLayout(title, body, profile); + let metaHtml = profileMetaHtml(profile); + metaHtml += feedLinksHtml(profile); + + return htmlLayout({ title, body, metaHtml }); } function openWithNostrAppHtml(bech32Id: string): string { @@ -156,3 +162,40 @@ function openWithNostrAppHtml(bech32Id: string): string { `; } + +function feedLinksHtml(profile) { + return ``; +} + +function profileMetaHtml(profile: Profile) { + return ` + + + + + + + + + + + + `; +} + +function articleMetaHtml(article: Article, profile: Profile) { + const imageUrl = article.image || profile.ogImageUrl; + + return ` + + + + + + + + + + + `; +} diff --git a/models/article.ts b/models/article.ts index 7e2249b..9cc925f 100644 --- a/models/article.ts +++ b/models/article.ts @@ -28,6 +28,11 @@ export default class Article { return tag ? tag[1] : "Untitled"; } + get image(): string { + const tag = this.event.tags.find((t) => t[0] === "image"); + return tag ? tag[1] : undefined; + } + get summary(): string { const tag = this.event.tags.find((t) => t[0] === "summary"); return tag ? tag[1] : ""; diff --git a/models/profile.ts b/models/profile.ts index 8e29606..e50759a 100644 --- a/models/profile.ts +++ b/models/profile.ts @@ -1,5 +1,5 @@ import { nip19, NostrEvent as NEvent } from "@nostr/tools"; -// import { NEvent } from "../nostr.ts"; +import config from "../config.ts"; export interface ProfileData { name?: string; @@ -54,4 +54,12 @@ export default class Profile { get npub(): string { return nip19.npubEncode(this.pubkey); } + + get profileUrl(): string { + return `${config.base_url}/@${this.username}`; + } + + get ogImageUrl(): string { + return `${config.base_url}/assets/g/img/og-p-${this.event.id}.png`; + } } diff --git a/server.ts b/server.ts index 22ab0a2..4b89d72 100644 --- a/server.ts +++ b/server.ts @@ -1,4 +1,5 @@ import { Application, Router, send } from "@oak/oak"; +import { createSubtrTmpDirectories } from "./utils.ts"; import config from "./config.ts"; import naddrHandler from "./handlers/naddr.ts"; import nprofileHandler from "./handlers/nprofile.ts"; @@ -54,16 +55,24 @@ router.get("/:username/:identifier", async (ctx) => { router.get("/assets/:path*", async (ctx) => { try { - const filePath = ctx.params.path || ""; + let filePath = ctx.params.path || ""; + let root: string; - await send(ctx, filePath, { - root: `${Deno.cwd()}/assets`, - }); + if (filePath.startsWith("g/img/")) { + filePath = filePath.replace(/^g\//, ""); + root = "/tmp/substr"; + } else { + root = `${Deno.cwd()}/assets`; + } + + await send(ctx, filePath, { root }); } catch (_e) { notFoundHandler(ctx); } }); +await createSubtrTmpDirectories(); + const app = new Application(); app.use(router.routes()); app.use(router.allowedMethods()); diff --git a/tasks/generate_og_profile_image.ts b/tasks/generate_og_profile_image.ts new file mode 100644 index 0000000..9dc91dd --- /dev/null +++ b/tasks/generate_og_profile_image.ts @@ -0,0 +1,63 @@ +import Profile from "../models/profile.ts"; +import { checkIfFileExists, runCommand } from "../utils.ts"; +import { log } from "../log.ts"; + +const tmpImgDir = "/tmp/substr/img"; + +const createRoundedImage = function (profile: Profile) { + const command = [ + 'magick', + profile.picture, + '-resize', '256x256', + '(', '+clone', '-alpha', 'extract', + '-draw', "fill black polygon 0,0 0,128 128,0 fill white circle 128,128 128,0", + '(', '+clone', '-flip', ')', '-compose', 'Multiply', '-composite', + '(', '+clone', '-flop', ')', '-compose', 'Multiply', '-composite', + ')', + '-alpha', 'off', + '-compose', 'CopyOpacity', + '-composite', + `${tmpImgDir}/p-${profile.event.id}-rounded.png` + ]; + + return runCommand(command); +} + +const createOgImage = async function (profile: Profile, ogImagePath: string, backgroundColor: string) { + const status = await createRoundedImage(profile); + + if (status.success) { + const command = [ + 'magick', + `${tmpImgDir}/p-${profile.event.id}-rounded.png`, + '-resize', '256x256', + '-background', backgroundColor, + '-gravity', 'center', + '-extent', '1200x630', + '-size', '1200x630', + "-format", "png", + ogImagePath + ]; + + return runCommand(command); + } +}; + +const generateOgProfileImage = async function(profile: Profile) { + if (!profile.picture) return false; + + const ogImagePath = `${tmpImgDir}/og-p-${profile.event.id}.png`; + const backgroundColor = "#333333"; + const fileExists = await checkIfFileExists(ogImagePath); + + if (!fileExists) { + const status = await createOgImage(profile, ogImagePath, backgroundColor); + if (status.success) { + log(`Created OG image for ${profile.username}: ${ogImagePath}`, "blue") + } else { + log(`Could not create OG image for ${profile.username}`, "yellow") + } + } +} + +export default generateOgProfileImage; diff --git a/utils.ts b/utils.ts new file mode 100644 index 0000000..459c08c --- /dev/null +++ b/utils.ts @@ -0,0 +1,29 @@ +export async function checkIfFileExists(filePath: string): boolean { + try { + await Deno.lstat(filePath); + return true; + } catch (error) { + if (error instanceof Deno.errors.NotFound) { + return false; + } else { + throw error; + } + } +} + +export async function createSubtrTmpDirectories(): void { + const dirs = [ + "/tmp/substr/img/" + ] + + for (const path of dirs) { + await Deno.mkdir(path, { recursive: true }); + } +} + +export async function runCommand(command) { + const process = Deno.run({ cmd: command }); + const status = await process.status(); + process.close(); + return status; +}