Add docker publish flow, node blocklist & API updates

Replace docker/metadata GH Action with a docker-publish.sh driven workflow (supports auto-versioning, extra tags, multi-arch builds and optional builder). Update docs and READMEs to use the updater and new publish flow. Add server-side swarm/node blocklist support and admin nodes API. Improve auth endpoints (me, logout, switch, session accounts) and support account switching. Extend bots API to support custom LLM provider and optional endpoint. Enforce node blocklist on chat send/receive, refactor link preview handling into dedicated preview modules, and enhance notifications and posts like/repost handlers to accept both signed actions and regular auth flows. Misc: remove admin update UI details, add helper libs (media previews, node-blocklist, reposts, notifications, etc.), and minor housekeeping (pyc, workflow tweaks).
This commit is contained in:
cyph3rasi
2026-03-10 12:40:05 -07:00
parent 044196cfa7
commit b4a9d82d05
79 changed files with 4714 additions and 1114 deletions
+24 -104
View File
@@ -1,8 +1,8 @@
import { NextRequest, NextResponse } from 'next/server';
import type { LinkPreviewData } from '@/lib/media/linkPreview';
import { fetchRedditRichPreview } from '@/lib/media/redditPreview';
import { fetchGenericLinkPreview } from '@/lib/media/genericPreview';
/**
* Check if a URL is from Reddit.
*/
function isRedditUrl(url: string): boolean {
try {
const parsed = new URL(url);
@@ -12,58 +12,16 @@ function isRedditUrl(url: string): boolean {
}
}
/**
* Fetch preview for Reddit URLs using their oEmbed API.
*/
async function fetchRedditPreview(url: string): Promise<{
url: string;
title: string | null;
description: string | null;
image: string | null;
} | null> {
try {
const oembedUrl = `https://www.reddit.com/oembed?url=${encodeURIComponent(url)}`;
const response = await fetch(oembedUrl, {
headers: { 'Accept': 'application/json' },
signal: AbortSignal.timeout(5000),
});
if (!response.ok) {
return null;
}
const data = await response.json();
// Extract title - try title field first, then parse from HTML
let title = data.title || null;
if (!title && data.html) {
const titleMatch = data.html.match(/href="[^"]+">([^<]+)<\/a>/);
if (titleMatch && titleMatch[1] && titleMatch[1] !== 'Comment') {
title = titleMatch[1];
}
}
// Build description from subreddit info
let description = null;
if (data.author_name) {
description = `Posted by ${data.author_name}`;
} else if (data.html) {
const subredditMatch = data.html.match(/r\/([a-zA-Z0-9_]+)/);
if (subredditMatch) {
description = `r/${subredditMatch[1]}`;
}
}
return {
url,
title,
description,
image: data.thumbnail_url || null,
};
} catch {
return null;
}
function buildBasicPreview(url: string, title?: string | null, description?: string | null, image?: string | null): LinkPreviewData {
return {
url,
title: title || url,
description: description || null,
image: image || null,
type: image ? 'image' : 'card',
videoUrl: null,
media: image ? [{ url: image }] : null,
};
}
export async function GET(req: NextRequest) {
@@ -75,68 +33,30 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ error: 'No URL provided' }, { status: 400 });
}
// Normalize URL
if (!url.startsWith('http://') && !url.startsWith('https://')) {
url = 'https://' + url;
}
// Use Reddit-specific handler
if (isRedditUrl(url)) {
const preview = await fetchRedditPreview(url);
const preview = await fetchRedditRichPreview(url);
if (preview) {
return NextResponse.json(preview);
}
// Fall back to URL-only response if oEmbed fails
return NextResponse.json({
url,
title: 'Reddit',
description: null,
image: null,
});
return NextResponse.json(buildBasicPreview(url, 'Reddit'));
}
// Generic OG tag scraping for other sites
let response;
try {
response = await fetch(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; SynapsisBot/1.0; +https://synapsis.social)',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.5',
},
signal: AbortSignal.timeout(5000),
});
} catch (fetchError) {
console.warn(`Fetch failed for URL: ${url}`, fetchError);
const preview = await fetchGenericLinkPreview(url);
if (!preview) {
return NextResponse.json({ error: 'Could not reach the URL' }, { status: 404 });
}
if (!response.ok) {
return NextResponse.json({ error: `URL returned status ${response.status}` }, { status: 404 });
}
const html = await response.text();
const getMeta = (property: string) => {
const regex = new RegExp(`<meta[^>]+(?:property|name)=["'](?:og:)?${property}["'][^>]+content=["']([^"']+)["']`, 'i');
const match = html.match(regex);
if (match) return match[1];
const regexRev = new RegExp(`<meta[^>]+content=["']([^"']+)["'][^>]+(?:property|name)=["'](?:og:)?${property}["']`, 'i');
const matchRev = html.match(regexRev);
return matchRev ? matchRev[1] : null;
};
const title = getMeta('title') || html.match(/<title>([^<]+)<\/title>/i)?.[1];
const description = getMeta('description');
const image = getMeta('image');
return NextResponse.json({
url,
title: title?.trim() || url,
description: description?.trim() || null,
image: image?.trim() || null,
});
return NextResponse.json(buildBasicPreview(
preview.url,
preview.title?.trim() || url,
preview.description?.trim() || null,
preview.image?.trim() || null,
));
} catch (error) {
console.error('Link preview error:', error);
return NextResponse.json({ error: 'Failed to fetch preview' }, { status: 500 });