Compare commits
No commits in common. "f3048a736357ad2ce7139d0bf145a0fe3f60ce43" and "0a34e3de47bd73fc7dfea12e9d75f18271e76b05" have entirely different histories.
f3048a7363
...
0a34e3de47
@ -20,7 +20,7 @@ FROM node:22-alpine AS runner
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV PORT=8302
|
ENV PORT=4002
|
||||||
ENV HOSTNAME=0.0.0.0
|
ENV HOSTNAME=0.0.0.0
|
||||||
|
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
@ -31,9 +31,9 @@ COPY --from=builder /app/.next ./.next
|
|||||||
COPY --from=builder /app/public ./public
|
COPY --from=builder /app/public ./public
|
||||||
COPY --from=builder /app/data ./data
|
COPY --from=builder /app/data ./data
|
||||||
|
|
||||||
EXPOSE 8302
|
EXPOSE 4002
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||||
CMD wget -q -O - http://127.0.0.1:8302/favicon.ico > /dev/null || exit 1
|
CMD wget -q -O - http://127.0.0.1:4002/favicon.ico > /dev/null || exit 1
|
||||||
|
|
||||||
CMD ["npm", "run", "start", "--", "-p", "8302", "-H", "0.0.0.0"]
|
CMD ["npm", "run", "start", "--", "-p", "4002", "-H", "0.0.0.0"]
|
||||||
|
|||||||
@ -41,11 +41,6 @@ Set these in `.env.local`:
|
|||||||
- `POST /api/digest` requires `x-api-key: <CRON_API_KEY>`
|
- `POST /api/digest` requires `x-api-key: <CRON_API_KEY>`
|
||||||
- Used for cron-based digest publishing
|
- Used for cron-based digest publishing
|
||||||
|
|
||||||
## RSS feeds
|
|
||||||
|
|
||||||
- `GET /api/rss` - Standard digest RSS feed for blog/article consumers
|
|
||||||
- `GET /api/podcast/rss` - Podcast RSS feed (audio episodes)
|
|
||||||
|
|
||||||
## MP3 Audio Hosting
|
## MP3 Audio Hosting
|
||||||
|
|
||||||
Upload and host MP3 files for individual blog posts.
|
Upload and host MP3 files for individual blog posts.
|
||||||
|
|||||||
@ -6,10 +6,10 @@ services:
|
|||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
pull_policy: build
|
pull_policy: build
|
||||||
ports:
|
ports:
|
||||||
- "${APP_PORT:-8302}:8302"
|
- "4002:4002"
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
PORT: "8302"
|
PORT: "4002"
|
||||||
HOSTNAME: 0.0.0.0
|
HOSTNAME: 0.0.0.0
|
||||||
NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL}
|
NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL}
|
||||||
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY}
|
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY}
|
||||||
@ -18,7 +18,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- blog_backup_runtime:/app/.runtime
|
- blog_backup_runtime:/app/.runtime
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "wget -q -O - http://127.0.0.1:8302/favicon.ico > /dev/null || exit 1"]
|
test: ["CMD-SHELL", "wget -q -O - http://127.0.0.1:4002/favicon.ico > /dev/null || exit 1"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|||||||
@ -1,143 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { createClient } from "@supabase/supabase-js";
|
|
||||||
import { extractExcerpt, extractTitle } from "@/lib/podcast";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
interface DigestRow {
|
|
||||||
id: string;
|
|
||||||
date: string | null;
|
|
||||||
content: string;
|
|
||||||
timestamp: number | string;
|
|
||||||
tags: string[] | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeXml(input: string): string {
|
|
||||||
return input
|
|
||||||
.replace(/&/g, "&")
|
|
||||||
.replace(/</g, "<")
|
|
||||||
.replace(/>/g, ">")
|
|
||||||
.replace(/"/g, """)
|
|
||||||
.replace(/'/g, "'");
|
|
||||||
}
|
|
||||||
|
|
||||||
function cdataSafe(input: string): string {
|
|
||||||
return input.replaceAll("]]>", "]]]]><![CDATA[>");
|
|
||||||
}
|
|
||||||
|
|
||||||
function asRfc2822(value: string | number | null | undefined): string {
|
|
||||||
if (typeof value === "number") {
|
|
||||||
return new Date(value).toUTCString();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof value === "string" && value.trim().length > 0) {
|
|
||||||
const numeric = Number(value);
|
|
||||||
if (Number.isFinite(numeric)) {
|
|
||||||
return new Date(numeric).toUTCString();
|
|
||||||
}
|
|
||||||
return new Date(value).toUTCString();
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Date().toUTCString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildErrorFeed(baseUrl: string): string {
|
|
||||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<rss version="2.0">
|
|
||||||
<channel>
|
|
||||||
<title>OpenClaw Daily Digest</title>
|
|
||||||
<link>${escapeXml(baseUrl)}</link>
|
|
||||||
<description>Error loading digest RSS feed. Please try again later.</description>
|
|
||||||
</channel>
|
|
||||||
</rss>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
|
||||||
const url = new URL(request.url);
|
|
||||||
const baseUrl =
|
|
||||||
process.env.NEXT_PUBLIC_SITE_URL?.replace(/\/$/, "") ||
|
|
||||||
`${url.protocol}//${url.host}`;
|
|
||||||
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
|
||||||
const supabaseKey =
|
|
||||||
process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
|
||||||
|
|
||||||
if (!supabaseUrl || !supabaseKey) {
|
|
||||||
return new NextResponse(buildErrorFeed(baseUrl), {
|
|
||||||
status: 500,
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/xml; charset=utf-8",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const supabase = createClient(supabaseUrl, supabaseKey);
|
|
||||||
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from("blog_messages")
|
|
||||||
.select("id, date, content, timestamp, tags")
|
|
||||||
.order("timestamp", { ascending: false })
|
|
||||||
.limit(100);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rows = (data || []) as DigestRow[];
|
|
||||||
|
|
||||||
const itemsXml = rows
|
|
||||||
.map((row) => {
|
|
||||||
const title = escapeXml(extractTitle(row.content || "Daily Digest"));
|
|
||||||
const excerpt = escapeXml(extractExcerpt(row.content || "", 420));
|
|
||||||
const pubDate = asRfc2822(row.timestamp || row.date);
|
|
||||||
const postUrl = `${baseUrl}/?post=${row.id}`;
|
|
||||||
const guid = `openclaw-digest-${row.id}`;
|
|
||||||
const categories = (row.tags || [])
|
|
||||||
.map((tag) => `<category>${escapeXml(tag)}</category>`)
|
|
||||||
.join("");
|
|
||||||
|
|
||||||
return `<item>
|
|
||||||
<title>${title}</title>
|
|
||||||
<link>${escapeXml(postUrl)}</link>
|
|
||||||
<guid isPermaLink="false">${escapeXml(guid)}</guid>
|
|
||||||
<pubDate>${pubDate}</pubDate>
|
|
||||||
<description>${excerpt}</description>
|
|
||||||
<content:encoded><![CDATA[${cdataSafe(row.content || "")}]]></content:encoded>
|
|
||||||
${categories}
|
|
||||||
</item>`;
|
|
||||||
})
|
|
||||||
.join("\n");
|
|
||||||
|
|
||||||
const lastBuildDate =
|
|
||||||
rows.length > 0 ? asRfc2822(rows[0].timestamp || rows[0].date) : new Date().toUTCString();
|
|
||||||
|
|
||||||
const feedXml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
|
|
||||||
<channel>
|
|
||||||
<title>OpenClaw Daily Digest</title>
|
|
||||||
<link>${escapeXml(baseUrl)}</link>
|
|
||||||
<description>Daily digest posts from OpenClaw Blog Backup.</description>
|
|
||||||
<language>en-us</language>
|
|
||||||
<lastBuildDate>${lastBuildDate}</lastBuildDate>
|
|
||||||
<atom:link href="${escapeXml(`${baseUrl}/api/rss`)}" rel="self" type="application/rss+xml"/>
|
|
||||||
${itemsXml}
|
|
||||||
</channel>
|
|
||||||
</rss>`;
|
|
||||||
|
|
||||||
return new NextResponse(feedXml, {
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/xml; charset=utf-8",
|
|
||||||
"Cache-Control": "public, max-age=300",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error generating digest RSS feed:", error);
|
|
||||||
return new NextResponse(buildErrorFeed(baseUrl), {
|
|
||||||
status: 500,
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/xml; charset=utf-8",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -9,12 +9,6 @@ import Head from "next/head";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { AudioPlayer } from "@/components/AudioPlayer";
|
import { AudioPlayer } from "@/components/AudioPlayer";
|
||||||
|
|
||||||
// Helper to parse date string (YYYY-MM-DD) correctly across timezones
|
|
||||||
// Appends noon UTC to ensure consistent date display regardless of local timezone
|
|
||||||
function parseDate(dateStr: string): Date {
|
|
||||||
return new Date(`${dateStr}T12:00:00Z`);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Message {
|
interface Message {
|
||||||
id: string;
|
id: string;
|
||||||
date: string;
|
date: string;
|
||||||
@ -225,7 +219,7 @@ function BlogPageContent() {
|
|||||||
{getTitle(selectedPost.content)}
|
{getTitle(selectedPost.content)}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-gray-500 dark:text-slate-400">
|
<p className="text-sm text-gray-500 dark:text-slate-400">
|
||||||
{format(parseDate(selectedPost.date), "MMMM d, yyyy")}
|
{format(new Date(selectedPost.date), "MMMM d, yyyy")}
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@ -313,7 +307,7 @@ function BlogPageContent() {
|
|||||||
{getExcerpt(featuredPost.content, 200)}
|
{getExcerpt(featuredPost.content, 200)}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-4 text-sm text-gray-500 dark:text-slate-400">
|
<div className="flex items-center gap-4 text-sm text-gray-500 dark:text-slate-400">
|
||||||
<span>{format(parseDate(featuredPost.date), "MMMM d, yyyy")}</span>
|
<span>{format(new Date(featuredPost.date), "MMMM d, yyyy")}</span>
|
||||||
<span>·</span>
|
<span>·</span>
|
||||||
<span>5 min read</span>
|
<span>5 min read</span>
|
||||||
</div>
|
</div>
|
||||||
@ -333,10 +327,10 @@ function BlogPageContent() {
|
|||||||
{/* Date Column */}
|
{/* Date Column */}
|
||||||
<div className="hidden sm:block text-center min-w-[60px]">
|
<div className="hidden sm:block text-center min-w-[60px]">
|
||||||
<div className="text-2xl font-bold text-gray-900 dark:text-slate-100">
|
<div className="text-2xl font-bold text-gray-900 dark:text-slate-100">
|
||||||
{format(parseDate(post.date), "d")}
|
{format(new Date(post.date), "d")}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-500 dark:text-slate-400 uppercase">
|
<div className="text-xs text-gray-500 dark:text-slate-400 uppercase">
|
||||||
{format(parseDate(post.date), "MMM")}
|
{format(new Date(post.date), "MMM")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -367,7 +361,7 @@ function BlogPageContent() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 text-xs text-gray-500 dark:text-slate-400">
|
<div className="flex items-center gap-3 text-xs text-gray-500 dark:text-slate-400">
|
||||||
<span>{format(parseDate(post.date), "MMMM d, yyyy")}</span>
|
<span>{format(new Date(post.date), "MMMM d, yyyy")}</span>
|
||||||
<span>·</span>
|
<span>·</span>
|
||||||
<span>3 min read</span>
|
<span>3 min read</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -126,10 +126,7 @@ export default function PodcastPage() {
|
|||||||
setProgress(0);
|
setProgress(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
const siteUrl =
|
const rssUrl = "https://blog-backup-two.vercel.app/api/podcast/rss";
|
||||||
process.env.NEXT_PUBLIC_SITE_URL?.replace(/\/$/, "") ||
|
|
||||||
"https://blog-backup-two.vercel.app";
|
|
||||||
const rssUrl = `${siteUrl}/api/rss`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user