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;
+}