parent
d5793d47ff
commit
fdd16d8236
@ -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;
|
||||
}
|
||||
|
@ -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",
|
||||
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
@ -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 {
|
||||
|
63
html.ts
63
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 =
|
||||
`<link rel="alternate" type="application/atom+xml" href="/@${profile.username}/articles.atom" title="Articles by ${profile.name}" />`;
|
||||
}
|
||||
interface HtmlLayoutOptions {
|
||||
title: string;
|
||||
body: string;
|
||||
metaHtml?: string;
|
||||
}
|
||||
|
||||
function htmlLayout({ title, body, metaHtml }: HtmlLayoutOptions): string {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
@ -17,7 +17,7 @@ function htmlLayout(title: string, body: string, profile?: Profile): string {
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
||||
<title>${title}</title>
|
||||
${feedLinksHtml}
|
||||
${metaHtml || ""}
|
||||
<link rel="stylesheet" type="text/css" href="/assets/css/layout.css" />
|
||||
<link rel="stylesheet" type="text/css" href="/assets/css/themes/default-light.css" />
|
||||
</head>
|
||||
@ -35,7 +35,7 @@ export function errorPageHtml(statusCode: number, title: string): string {
|
||||
</main>
|
||||
`;
|
||||
|
||||
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 {
|
||||
</main>
|
||||
`;
|
||||
|
||||
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 {
|
||||
</main>
|
||||
`;
|
||||
|
||||
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 {
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function feedLinksHtml(profile) {
|
||||
return `<link rel="alternate" type="application/atom+xml" href="/@${profile.username}/articles.atom" title="Articles by ${profile.name}" />`;
|
||||
}
|
||||
|
||||
function profileMetaHtml(profile: Profile) {
|
||||
return `
|
||||
<meta property="og:url" content="${profile.profileUrl}">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:title" content="${profile.name} on Nostr">
|
||||
<meta property="og:description" content="">
|
||||
<meta property="og:image" content="${profile.ogImageUrl}">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta property="twitter:domain" content="nostr.kosmos.org">
|
||||
<meta property="twitter:url" content="${profile.profileUrl}">
|
||||
<meta name="twitter:title" content="${profile.name} on Nostr">
|
||||
<meta name="twitter:description" content="">
|
||||
<meta name="twitter:image" content="${profile.ogImageUrl}">
|
||||
`;
|
||||
}
|
||||
|
||||
function articleMetaHtml(article: Article, profile: Profile) {
|
||||
const imageUrl = article.image || profile.ogImageUrl;
|
||||
|
||||
return `
|
||||
<meta property="og:url" content="${article.url}">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:title" content="${article.title}">
|
||||
<meta property="og:description" content="${article.summary}">
|
||||
<meta property="og:image" content="${imageUrl}">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta property="twitter:url" content="${article.url}">
|
||||
<meta name="twitter:title" content="${article.title}">
|
||||
<meta name="twitter:description" content="${article.summary}">
|
||||
<meta name="twitter:image" content="${imageUrl}">
|
||||
`;
|
||||
}
|
||||
|
@ -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] : "";
|
||||
|
@ -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`;
|
||||
}
|
||||
}
|
||||
|
17
server.ts
17
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());
|
||||
|
63
tasks/generate_og_profile_image.ts
Normal file
63
tasks/generate_og_profile_image.ts
Normal file
@ -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;
|
29
utils.ts
Normal file
29
utils.ts
Normal file
@ -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;
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user