merge: combine 10-layer + Jaccard deduplication
Mergedf5db30(5-layer Jaccard dedup + crossPostedTo) intob9f61bb(10-layer dedup). Combined approach: - 10-layer dedup (canonical ID, fingerprint, normalized text, URL, prefix, short exact, author window, quoted, ticker snapshot, embed) - Jaccard word-set similarity check (from remote, threshold 0.7) - crossPostedTo enrichment on matched posts - Quotes/reposts skip Jaccard to avoid false dedup - extractQuotedId returns text fallback when RT/@via has no URLs 19 tests pass, lint + typecheck clean.
This commit is contained in:
commit
b665c51f4d
29 changed files with 849 additions and 26 deletions
31
apps/web/functions/_middleware.js
Normal file
31
apps/web/functions/_middleware.js
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
export async function onRequest(context) {
|
||||
const { request, next } = context;
|
||||
const url = new URL(request.url);
|
||||
const { pathname } = url;
|
||||
|
||||
if (
|
||||
pathname.startsWith("/_next/") ||
|
||||
pathname.startsWith("/api/") ||
|
||||
pathname.startsWith("/.well-known/") ||
|
||||
/\.\w+$/.test(pathname)
|
||||
) {
|
||||
return next();
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await next();
|
||||
if (response.status === 404) {
|
||||
const index = await context.env.ASSETS.fetch(
|
||||
new URL("/index.html", request.url)
|
||||
);
|
||||
if (index.ok) return index;
|
||||
}
|
||||
return response;
|
||||
} catch {
|
||||
const index = await context.env.ASSETS.fetch(
|
||||
new URL("/index.html", request.url)
|
||||
);
|
||||
if (index.ok) return index;
|
||||
return new Response("Not Found", { status: 404 });
|
||||
}
|
||||
}
|
||||
2
apps/web/next-env.d.ts
vendored
2
apps/web/next-env.d.ts
vendored
|
|
@ -1,6 +1,6 @@
|
|||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import './.next/types/routes.d.ts';
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
export const runtime = "edge";
|
||||
import type { Metadata } from 'next';
|
||||
import { SECTIONS } from '../sections';
|
||||
|
||||
|
|
|
|||
|
|
@ -33,7 +33,6 @@ export const metadata: Metadata = {
|
|||
},
|
||||
],
|
||||
},
|
||||
twitter: undefined,
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
export const runtime = "edge";
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
|
|
|
|||
|
|
@ -378,12 +378,64 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
auth.accessToken = token;
|
||||
auth.lens_profileId = identity?.publicId ?? '';
|
||||
}
|
||||
// Farcaster — requires a pre-signed message (complex, not auto-done yet).
|
||||
// Farcaster — sign and publish client-side (never send signer key to the server).
|
||||
else if (protocol === 'farcaster') {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'Farcaster engagement requires a client-signed message. Coming soon.',
|
||||
};
|
||||
const farcasterIdent = identities.find((i) => i.protocol === "farcaster");
|
||||
if (!farcasterIdent) return { ok: false, error: "No Farcaster identity linked" };
|
||||
|
||||
let signerKeyHex = null;
|
||||
if (farcasterIdent.encryptedSecret) {
|
||||
try {
|
||||
const { decryptSecret } = await import("@degenfeed/auth");
|
||||
signerKeyHex = await decryptSecret(
|
||||
{
|
||||
ciphertext: farcasterIdent.encryptedSecret,
|
||||
salt: farcasterIdent.salt,
|
||||
iv: farcasterIdent.iv,
|
||||
},
|
||||
"auto-unlock-passphrase",
|
||||
);
|
||||
} catch {
|
||||
return { ok: false, error: "Could not decrypt Farcaster signer key. Re-link your account." };
|
||||
}
|
||||
}
|
||||
if (!signerKeyHex) {
|
||||
return { ok: false, error: "Farcaster signer key not found. Sign in with a self-custody signer key." };
|
||||
}
|
||||
|
||||
const fid = parseInt(farcasterIdent.publicId, 10);
|
||||
if (isNaN(fid)) return { ok: false, error: "Invalid Farcaster FID" };
|
||||
|
||||
const nativeId = postId.startsWith("farcaster:")
|
||||
? postId.slice("farcaster:".length)
|
||||
: postId.startsWith("fc:")
|
||||
? postId.slice("fc:".length)
|
||||
: postId;
|
||||
|
||||
const targetFid = opts.target?.authorId
|
||||
? parseInt(opts.target.authorId, 10)
|
||||
: 0;
|
||||
const castHashHex = nativeId;
|
||||
|
||||
if (castHashHex.length < 40) {
|
||||
return { ok: false, error: "Invalid Farcaster cast hash" };
|
||||
}
|
||||
|
||||
if (action !== "like" && action !== "unlike" && action !== "repost" && action !== "reply") {
|
||||
return { ok: false, error: `${action} not supported on Farcaster` };
|
||||
}
|
||||
|
||||
const { engageFarcaster } = await import("@degenfeed/farcaster-sdk");
|
||||
const result = await engageFarcaster({
|
||||
action: action,
|
||||
signerKeyHex,
|
||||
fid,
|
||||
targetFid,
|
||||
castHashHex,
|
||||
content: opts.content,
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
// Generic fallback — send accessToken as protocol key.
|
||||
else {
|
||||
|
|
|
|||
|
|
@ -22,3 +22,4 @@ export {
|
|||
verifyWalletSignature,
|
||||
} from './providers/wallet';
|
||||
export { forgetIdentity, lockIdentity, unlockIdentity } from './session';
|
||||
export { signInWithReddit, handleRedditCallback } from "./providers/reddit";
|
||||
|
|
|
|||
53
packages/auth/src/providers/reddit.ts
Normal file
53
packages/auth/src/providers/reddit.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
/**
|
||||
* Reddit OAuth provider for real engagement (voting, commenting, posting).
|
||||
*
|
||||
* Setup: Register a Reddit app at https://www.reddit.com/prefs/apps
|
||||
* Set REDDIT_CLIENT_ID and REDDIT_CLIENT_SECRET in wrangler secrets.
|
||||
*/
|
||||
import type { StoredIdentity } from "@degenfeed/storage";
|
||||
import { buildIdentityRecord } from "../persistence";
|
||||
|
||||
export async function signInWithReddit(clientId: string): Promise<StoredIdentity> {
|
||||
const state = crypto.randomUUID();
|
||||
localStorage.setItem("reddit_oauth_state", state);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
response_type: "code",
|
||||
state,
|
||||
redirect_uri: `${window.location.origin}/auth/callback?provider=reddit`,
|
||||
duration: "permanent",
|
||||
scope: "identity read vote submit",
|
||||
});
|
||||
window.location.href = `https://www.reddit.com/api/v1/authorize?${params.toString()}`;
|
||||
throw new Error("Redirecting...");
|
||||
}
|
||||
|
||||
export async function handleRedditCallback(code: string, state: string): Promise<StoredIdentity> {
|
||||
const savedState = localStorage.getItem("reddit_oauth_state");
|
||||
localStorage.removeItem("reddit_oauth_state");
|
||||
if (!savedState || state !== savedState) throw new Error("OAuth state mismatch");
|
||||
|
||||
const res = await fetch("/api/auth/reddit/callback", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ code }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Reddit auth failed");
|
||||
const data = await res.json();
|
||||
|
||||
const record = await buildIdentityRecord({
|
||||
protocol: "reddit",
|
||||
publicId: data.id,
|
||||
handle: `u/${data.name}`,
|
||||
displayName: data.name,
|
||||
avatarUrl: data.icon_img,
|
||||
secret: data.accessToken,
|
||||
passphrase: "auto-unlock",
|
||||
keys: { accessToken: data.accessToken, refreshToken: data.refreshToken ?? "" },
|
||||
});
|
||||
const { saveIdentity } = await import("@degenfeed/storage");
|
||||
await saveIdentity(record);
|
||||
return record;
|
||||
}
|
||||
|
|
@ -15,7 +15,9 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@degenfeed/types": "workspace:*",
|
||||
"@degenfeed/feed-core": "workspace:*"
|
||||
"@degenfeed/feed-core": "workspace:*",
|
||||
"@noble/curves": "^1.6.0",
|
||||
"@noble/hashes": "^1.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.5.2",
|
||||
|
|
|
|||
235
packages/farcaster-sdk/src/engage.ts
Normal file
235
packages/farcaster-sdk/src/engage.ts
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
import { blake3 } from "@noble/hashes/blake3";
|
||||
import { ed25519 } from "@noble/curves/ed25519";
|
||||
|
||||
const FARCASTER_NETWORK_MAINNET = 1;
|
||||
|
||||
const MESSAGE_TYPE_CAST_ADD = 1;
|
||||
const MESSAGE_TYPE_REACTION_ADD = 6;
|
||||
const MESSAGE_TYPE_REACTION_REMOVE = 7;
|
||||
|
||||
const REACTION_TYPE_LIKE = 1;
|
||||
const REACTION_TYPE_RECAST = 2;
|
||||
|
||||
const HASH_SCHEME_BLAKE3 = 1;
|
||||
|
||||
function varintLen(value: number): number {
|
||||
if (value < 128) return 1;
|
||||
if (value < 16384) return 2;
|
||||
if (value < 2097152) return 3;
|
||||
if (value < 268435456) return 4;
|
||||
if (value < 34359738368) return 5;
|
||||
return 6;
|
||||
}
|
||||
|
||||
function encodeVarint(value: number): Uint8Array {
|
||||
const out: number[] = [];
|
||||
let v = value >>> 0;
|
||||
while (v > 127) {
|
||||
out.push((v & 0x7f) | 0x80);
|
||||
v >>>= 7;
|
||||
}
|
||||
out.push(v & 0x7f);
|
||||
return new Uint8Array(out);
|
||||
}
|
||||
|
||||
function encodeField(wireType: number, fieldNumber: number, payload: Uint8Array): Uint8Array {
|
||||
const key = encodeVarint((fieldNumber << 3) | wireType);
|
||||
const out = new Uint8Array(key.length + payload.length);
|
||||
out.set(key);
|
||||
out.set(payload, key.length);
|
||||
return out;
|
||||
}
|
||||
|
||||
function encodeLengthDelimited(fieldNumber: number, payload: Uint8Array): Uint8Array {
|
||||
const len = encodeVarint(payload.length);
|
||||
const field = encodeField(2, fieldNumber, new Uint8Array([...len, ...payload]));
|
||||
return field;
|
||||
}
|
||||
|
||||
function encodeVarintField(fieldNumber: number, value: number): Uint8Array {
|
||||
return encodeField(0, fieldNumber, encodeVarint(value));
|
||||
}
|
||||
|
||||
function hexToBytes(hex: string): Uint8Array {
|
||||
const bytes = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0; i < hex.length; i += 2) {
|
||||
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function concatBytes(...arrays: Uint8Array[]): Uint8Array {
|
||||
const total = arrays.reduce((s, a) => s + a.length, 0);
|
||||
const out = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const a of arrays) {
|
||||
out.set(a, offset);
|
||||
offset += a.length;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function encodeCastId(fid: number, hashHex: string): Uint8Array {
|
||||
const hashBytes = hexToBytes(hashHex);
|
||||
if (hashBytes.length !== 20) {
|
||||
throw new Error(`Cast hash must be 20 bytes (40 hex chars), got ${hashBytes.length} bytes`);
|
||||
}
|
||||
const inner = concatBytes(encodeVarintField(1, fid), encodeLengthDelimited(2, hashBytes));
|
||||
return inner;
|
||||
}
|
||||
|
||||
function encodeEmbedCastId(fid: number, hashHex: string): Uint8Array {
|
||||
const castId = encodeCastId(fid, hashHex);
|
||||
return encodeLengthDelimited(2, castId);
|
||||
}
|
||||
|
||||
function encodeReactionBody(
|
||||
reactionType: number,
|
||||
targetFid: number,
|
||||
targetHashHex: string
|
||||
): Uint8Array {
|
||||
const inner = concatBytes(
|
||||
encodeVarintField(1, reactionType),
|
||||
encodeLengthDelimited(2, encodeCastId(targetFid, targetHashHex))
|
||||
);
|
||||
return inner;
|
||||
}
|
||||
|
||||
function encodeCastAddBody(opts: {
|
||||
text?: string;
|
||||
parentFid?: number;
|
||||
parentHashHex?: string;
|
||||
embedCastFid?: number;
|
||||
embedCastHashHex?: string;
|
||||
}): Uint8Array {
|
||||
const parts: Uint8Array[] = [];
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
if (opts.text) {
|
||||
parts.push(encodeLengthDelimited(1, encoder.encode(opts.text)));
|
||||
}
|
||||
if (opts.embedCastFid !== undefined && opts.embedCastHashHex) {
|
||||
parts.push(encodeLengthDelimited(2, encodeEmbedCastId(opts.embedCastFid, opts.embedCastHashHex)));
|
||||
}
|
||||
if (opts.parentFid !== undefined && opts.parentHashHex) {
|
||||
parts.push(encodeLengthDelimited(5, encodeCastId(opts.parentFid, opts.parentHashHex)));
|
||||
}
|
||||
|
||||
return concatBytes(...parts);
|
||||
}
|
||||
|
||||
function encodeMessageData(opts: {
|
||||
type: number;
|
||||
fid: number;
|
||||
body: Uint8Array;
|
||||
bodyField: number;
|
||||
timestamp?: number;
|
||||
}): Uint8Array {
|
||||
const ts = opts.timestamp ?? Math.floor(Date.now() / 1000);
|
||||
const parts = [
|
||||
encodeVarintField(1, opts.type),
|
||||
encodeVarintField(2, opts.fid),
|
||||
encodeVarintField(3, ts),
|
||||
encodeVarintField(4, FARCASTER_NETWORK_MAINNET),
|
||||
encodeLengthDelimited(opts.bodyField, opts.body),
|
||||
];
|
||||
return concatBytes(...parts);
|
||||
}
|
||||
|
||||
export interface FarcasterEngageResult {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface FarcasterEngageOpts {
|
||||
action: "like" | "unlike" | "repost" | "reply";
|
||||
signerKeyHex: string;
|
||||
fid: number;
|
||||
targetFid: number;
|
||||
castHashHex: string;
|
||||
content?: string;
|
||||
}
|
||||
|
||||
export async function engageFarcaster(opts: FarcasterEngageOpts): Promise<FarcasterEngageResult> {
|
||||
try {
|
||||
const signerBytes = hexToBytes(opts.signerKeyHex);
|
||||
if (signerBytes.length !== 32) {
|
||||
return { ok: false, error: "Invalid signer key (must be 32 bytes / 64 hex chars)" };
|
||||
}
|
||||
|
||||
const pubKey = ed25519.getPublicKey(signerBytes);
|
||||
|
||||
let messageType: number;
|
||||
let bodyField: number;
|
||||
let body: Uint8Array;
|
||||
|
||||
if (opts.action === "like") {
|
||||
messageType = MESSAGE_TYPE_REACTION_ADD;
|
||||
bodyField = 7;
|
||||
body = encodeReactionBody(REACTION_TYPE_LIKE, opts.targetFid, opts.castHashHex);
|
||||
} else if (opts.action === "unlike") {
|
||||
messageType = MESSAGE_TYPE_REACTION_REMOVE;
|
||||
bodyField = 7;
|
||||
body = encodeReactionBody(REACTION_TYPE_LIKE, opts.targetFid, opts.castHashHex);
|
||||
} else if (opts.action === "repost") {
|
||||
messageType = MESSAGE_TYPE_CAST_ADD;
|
||||
bodyField = 5;
|
||||
body = encodeCastAddBody({
|
||||
embedCastFid: opts.targetFid,
|
||||
embedCastHashHex: opts.castHashHex,
|
||||
});
|
||||
} else if (opts.action === "reply") {
|
||||
messageType = MESSAGE_TYPE_CAST_ADD;
|
||||
bodyField = 5;
|
||||
body = encodeCastAddBody({
|
||||
text: opts.content ?? "",
|
||||
parentFid: opts.targetFid,
|
||||
parentHashHex: opts.castHashHex,
|
||||
});
|
||||
} else {
|
||||
return { ok: false, error: `Unknown action: ${opts.action}` };
|
||||
}
|
||||
|
||||
const messageData = encodeMessageData({
|
||||
type: messageType,
|
||||
fid: opts.fid,
|
||||
body,
|
||||
bodyField,
|
||||
});
|
||||
|
||||
const hash = blake3(messageData, { dkLen: 20 });
|
||||
|
||||
const signature = ed25519.sign(hash, signerBytes);
|
||||
|
||||
const message = concatBytes(
|
||||
encodeLengthDelimited(1, messageData),
|
||||
encodeLengthDelimited(2, hash),
|
||||
encodeVarintField(3, HASH_SCHEME_BLAKE3),
|
||||
encodeLengthDelimited(4, signature),
|
||||
encodeLengthDelimited(5, pubKey),
|
||||
);
|
||||
|
||||
await submitToHub(message);
|
||||
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Farcaster engagement failed";
|
||||
return { ok: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
async function submitToHub(signedMessage: Uint8Array): Promise<void> {
|
||||
const hubUrl = "https://hub.farcaster.standardcrypto.vc:2281";
|
||||
const res = await fetch(`${hubUrl}/v1/submitMessage`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"content-type": "application/octet-stream",
|
||||
"user-agent": "DegenFeed/0.1 (+https://degenfeed.xyz)",
|
||||
},
|
||||
body: signedMessage.buffer as ArrayBuffer,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errText = await res.text().catch(() => "");
|
||||
throw new Error(`Farcaster Hub rejected: ${res.status} ${errText.slice(0, 200)}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import type { Protocol, UnifiedIdentity } from '@degenfeed/types';
|
||||
|
||||
export { engageFarcaster } from "./engage";
|
||||
export type { Protocol, UnifiedIdentity };
|
||||
|
||||
export const DEFAULT_HUB_URL = 'https://hub.farcaster.standardcrypto.vc:2281';
|
||||
|
|
|
|||
|
|
@ -419,6 +419,30 @@ function normalizeStripped(text: string): string {
|
|||
.trim();
|
||||
}
|
||||
|
||||
function wordSet(text: string): Set<string> {
|
||||
return new Set(normalizeStripped(text).split(' ').filter((w) => w.length > 2));
|
||||
}
|
||||
|
||||
function jaccardSimilarity(a: Set<string>, b: Set<string>): number {
|
||||
if (a.size === 0 || b.size === 0) return 0;
|
||||
let intersection = 0;
|
||||
const smaller = a.size < b.size ? a : b;
|
||||
const larger = a.size < b.size ? b : a;
|
||||
for (const w of smaller) {
|
||||
if (larger.has(w)) intersection++;
|
||||
}
|
||||
return intersection / (a.size + b.size - intersection);
|
||||
}
|
||||
|
||||
function extractUrls(post: UnifiedPost): string[] {
|
||||
const textUrls = (post.text.match(/https?:\/\/[^\s<>"']+/gi) ?? [])
|
||||
.map((u) => u.replace(/\/$/, '').toLowerCase());
|
||||
const embedUrls = post.embeds
|
||||
.filter((e) => e.kind === 'link' || e.kind === 'image' || e.kind === 'video')
|
||||
.map((e) => e.url.replace(/\/$/, '').toLowerCase());
|
||||
return [...new Set([...textUrls, ...embedUrls])];
|
||||
}
|
||||
|
||||
function primaryUrl(post: UnifiedPost): string | null {
|
||||
const urls = post.text.match(URL_RE);
|
||||
if (urls && urls.length > 0) return urls[0].toLowerCase();
|
||||
|
|
@ -481,6 +505,9 @@ export function contentFingerprint(post: UnifiedPost): string {
|
|||
return link ? `link:${link.url}` : '';
|
||||
}
|
||||
|
||||
const JACCARD_THRESHOLD = 0.7;
|
||||
const MIN_TEXT_LENGTH = 20;
|
||||
|
||||
export function deduplicatePosts(posts: UnifiedPost[]): UnifiedPost[] {
|
||||
const seen = {
|
||||
id: new Set<string>(),
|
||||
|
|
@ -494,6 +521,7 @@ export function deduplicatePosts(posts: UnifiedPost[]): UnifiedPost[] {
|
|||
ticker: new Set<string>(),
|
||||
embed: new Set<string>(),
|
||||
};
|
||||
const candidates: { words: Set<string>; index: number }[] = [];
|
||||
const out: UnifiedPost[] = [];
|
||||
|
||||
for (const p of posts) {
|
||||
|
|
@ -529,6 +557,22 @@ export function deduplicatePosts(posts: UnifiedPost[]): UnifiedPost[] {
|
|||
const eid = embedFingerprint(p);
|
||||
if (eid && seen.embed.has(eid)) continue;
|
||||
|
||||
const ws = wordSet(p.text);
|
||||
let matchedProtocol: Protocol | undefined;
|
||||
if (ws.size >= 3 && p.text.length > MIN_TEXT_LENGTH) {
|
||||
for (const cand of candidates) {
|
||||
const sim = jaccardSimilarity(ws, cand.words);
|
||||
if (sim >= JACCARD_THRESHOLD) {
|
||||
matchedProtocol = out[cand.index].protocol;
|
||||
const cross = out[cand.index].crossPostedTo ?? [];
|
||||
if (!cross.includes(p.protocol)) cross.push(p.protocol);
|
||||
out[cand.index].crossPostedTo = cross;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (matchedProtocol) continue;
|
||||
|
||||
seen.id.add(p.id);
|
||||
if (fp) seen.fingerprint.add(fp);
|
||||
if (norm.length > 15) seen.normalized.add(norm);
|
||||
|
|
@ -538,9 +582,10 @@ export function deduplicatePosts(posts: UnifiedPost[]): UnifiedPost[] {
|
|||
if (qid) seen.quoted.add(qid);
|
||||
if (ts.length > 3) seen.ticker.add(ts);
|
||||
if (eid) seen.embed.add(eid);
|
||||
|
||||
candidates.push({ words: ws, index: out.length });
|
||||
out.push(p);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -35,3 +35,4 @@ export {
|
|||
withRetry,
|
||||
withTimeout,
|
||||
} from './utils';
|
||||
export { PonderProvider } from "./providers/ponder";
|
||||
|
|
|
|||
146
packages/feed-providers/src/providers/ponder.ts
Normal file
146
packages/feed-providers/src/providers/ponder.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import { detectNiches, buildUnifiedPost } from "@degenfeed/feed-core";
|
||||
import type { Protocol, UnifiedIdentity, UnifiedPost } from "@degenfeed/types";
|
||||
import type { FeedProvider, FeedResult, FetchOptions, HealthResult } from "../types";
|
||||
import { DEFAULT_TIMEOUT_MS, fetchJson, withTimeout } from "../utils";
|
||||
|
||||
interface PonderTransfer {
|
||||
id: string;
|
||||
fromAddress: string;
|
||||
toAddress: string;
|
||||
value: string;
|
||||
txHash: string;
|
||||
blockNumber: number;
|
||||
timestamp: number;
|
||||
chainId: number;
|
||||
tokenAddress: string;
|
||||
}
|
||||
|
||||
const CHAIN_NAMES: Record<number, string> = {
|
||||
1: "Ethereum",
|
||||
56: "BSC",
|
||||
137: "Polygon",
|
||||
42161: "Arbitrum",
|
||||
8453: "Base",
|
||||
};
|
||||
|
||||
const CHAIN_EXPLORERS: Record<number, string> = {
|
||||
1: "https://etherscan.io/tx",
|
||||
56: "https://bscscan.com/tx",
|
||||
137: "https://polygonscan.com/tx",
|
||||
42161: "https://arbiscan.io/tx",
|
||||
8453: "https://basescan.org/tx",
|
||||
};
|
||||
|
||||
const PONDER_ENDPOINTS: { port: number; chainId: number }[] = [
|
||||
{ port: 42069, chainId: 1 },
|
||||
{ port: 42070, chainId: 56 },
|
||||
{ port: 42071, chainId: 137 },
|
||||
{ port: 42072, chainId: 42161 },
|
||||
];
|
||||
|
||||
function transferToUnifiedPost(t: PonderTransfer): UnifiedPost {
|
||||
const chainName = CHAIN_NAMES[t.chainId] ?? `Chain ${t.chainId}`;
|
||||
const explorer = CHAIN_EXPLORERS[t.chainId];
|
||||
const valueNum = Number(t.value) / 1e18;
|
||||
const shortValue = valueNum > 1000000
|
||||
? `$${(valueNum / 1e6).toFixed(2)}M`
|
||||
: valueNum > 1000
|
||||
? `$${(valueNum / 1e3).toFixed(1)}K`
|
||||
: `$${valueNum.toFixed(2)}`;
|
||||
|
||||
const fromShort = `${t.fromAddress.slice(0, 6)}...${t.fromAddress.slice(-4)}`;
|
||||
const toShort = `${t.toAddress.slice(0, 6)}...${t.toAddress.slice(-4)}`;
|
||||
const text = `${shortValue} USDC transferred on ${chainName}\nFrom: ${fromShort}\nTo: ${toShort}`;
|
||||
const txUrl = explorer ? `${explorer}/${t.txHash}` : undefined;
|
||||
|
||||
const identity: UnifiedIdentity = {
|
||||
id: `ponder:${t.chainId}`,
|
||||
primary: { protocol: "ponder" as Protocol, id: String(t.chainId) },
|
||||
handles: { nostr: null, farcaster: null, lens: null, bluesky: null, threads: null, rss: null, mastodon: null },
|
||||
displayName: `${chainName} On-Chain`,
|
||||
bio: `Token transfers on ${chainName}`,
|
||||
avatarUrl: "",
|
||||
verified: [],
|
||||
followerCount: 0,
|
||||
followingCount: 0,
|
||||
links: [],
|
||||
keys: {},
|
||||
};
|
||||
|
||||
const embeds = txUrl ? [{ kind: "link" as const, url: txUrl }] : [];
|
||||
|
||||
return buildUnifiedPost({
|
||||
protocol: "ponder" as Protocol,
|
||||
nativeId: t.id,
|
||||
author: identity,
|
||||
text,
|
||||
embeds,
|
||||
createdAt: t.timestamp * 1000,
|
||||
metrics: { likes: 0, reposts: 0, replies: 0, quotes: 0 },
|
||||
raw: t,
|
||||
});
|
||||
}
|
||||
|
||||
export class PonderProvider implements FeedProvider {
|
||||
readonly protocol: Protocol = "ponder" as Protocol;
|
||||
private readonly baseUrl: string;
|
||||
|
||||
constructor(baseUrl = "http://127.0.0.1") {
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
async fetchFeed(opts: FetchOptions = {}): Promise<FeedResult> {
|
||||
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
const posts: UnifiedPost[] = [];
|
||||
|
||||
for (const ep of PONDER_ENDPOINTS) {
|
||||
try {
|
||||
const url = `${this.baseUrl}:${ep.port}/graphql`;
|
||||
const query = {
|
||||
query: `{ tokenTransfers(limit: 25, orderBy: "timestamp", orderDirection: "desc") { items { id fromAddress toAddress value txHash blockNumber timestamp chainId tokenAddress } } }`,
|
||||
};
|
||||
const data = await withTimeout(
|
||||
fetchJson<{ data?: { tokenTransfers?: { items?: PonderTransfer[] } } }>(url, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(query),
|
||||
}),
|
||||
timeoutMs,
|
||||
`ponder:${ep.chainId}`,
|
||||
);
|
||||
const items = data?.data?.tokenTransfers?.items ?? [];
|
||||
for (const t of items) {
|
||||
posts.push(transferToUnifiedPost(t));
|
||||
}
|
||||
} catch {
|
||||
// Ponder instance not ready yet — skip
|
||||
}
|
||||
}
|
||||
|
||||
for (const p of posts) {
|
||||
p.niches = detectNiches(p.text);
|
||||
}
|
||||
|
||||
return { posts };
|
||||
}
|
||||
|
||||
async healthCheck(opts: FetchOptions = {}): Promise<HealthResult> {
|
||||
const timeoutMs = opts.timeoutMs ?? 5000;
|
||||
const start = Date.now();
|
||||
try {
|
||||
const url = `${this.baseUrl}:42070/graphql`;
|
||||
await withTimeout(
|
||||
fetchJson(url, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ query: "{ _meta { status { bsc { block ready } } } }" }),
|
||||
}),
|
||||
timeoutMs,
|
||||
"ponder-health",
|
||||
);
|
||||
return { healthy: true, latencyMs: Date.now() - start };
|
||||
} catch {
|
||||
return { healthy: false, latencyMs: Date.now() - start };
|
||||
}
|
||||
}
|
||||
}
|
||||
45
packages/feed-providers/src/providers/reddit-engage.ts
Normal file
45
packages/feed-providers/src/providers/reddit-engage.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import type { UnifiedPost } from "@degenfeed/types";
|
||||
|
||||
const REDLIB_URL = typeof process !== "undefined"
|
||||
? (process.env?.REDLIB_URL ?? "http://127.0.0.1:8281")
|
||||
: "http://127.0.0.1:8281";
|
||||
|
||||
export interface RedditEngageResult {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function engageReddit(opts: {
|
||||
action: "like" | "unlike" | "reply" | "repost";
|
||||
postId: string;
|
||||
subreddit: string;
|
||||
commentId?: string;
|
||||
content?: string;
|
||||
token?: string;
|
||||
}): Promise<RedditEngageResult> {
|
||||
try {
|
||||
const effectiveAction = opts.action === "like" ? "upvote" : opts.action === "unlike" ? "downvote" : opts.action;
|
||||
if (effectiveAction === "reply") {
|
||||
return { ok: false, error: "Reddit comment posting requires OAuth. Coming soon." };
|
||||
}
|
||||
if (effectiveAction === "repost") {
|
||||
return { ok: false, error: "Reddit crosspost requires OAuth. Coming soon." };
|
||||
}
|
||||
// Voting via Redlib: POST /r/{sub}/api/vote with id=postId&dir=1 (up) or -1 (down)
|
||||
const formBody = new URLSearchParams({
|
||||
id: `t3_${opts.postId}`,
|
||||
dir: effectiveAction === "upvote" ? "1" : "-1",
|
||||
});
|
||||
const res = await fetch(`${REDLIB_URL}/r/${opts.subreddit}/api/vote`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
body: formBody.toString(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
return { ok: false, error: `Redlib vote failed: ${res.status}` };
|
||||
}
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err instanceof Error ? err.message : "Reddit engagement failed" };
|
||||
}
|
||||
}
|
||||
|
|
@ -51,16 +51,20 @@ export const DEFAULT_SUBREDDITS = [
|
|||
'cryptomining',
|
||||
];
|
||||
|
||||
// Open-source Reddit API frontends (libreddit, teddit, etc.)
|
||||
// These proxy Reddit without auth and return JSON.
|
||||
const REDDIT_FRONTENDS = [
|
||||
// Self-hosted Redlib on Talos (primary). Falls back to community instances.
|
||||
const SELF_HOSTED_REDLIB = typeof process !== "undefined"
|
||||
? (process.env?.REDLIB_URL ?? "http://127.0.0.1:8281")
|
||||
: "http://127.0.0.1:8281";
|
||||
|
||||
const COMMUNITY_REDDIT_FRONTENDS = [
|
||||
'https://libreddit.kavin.rocks',
|
||||
'https://libreddit.privacydev.net',
|
||||
'https://lr.vern.cc',
|
||||
'https://teddit.net',
|
||||
'https://red.ngn.tf',
|
||||
];
|
||||
|
||||
const REDDIT_FRONTENDS = [SELF_HOSTED_REDLIB, ...COMMUNITY_REDDIT_FRONTENDS];
|
||||
|
||||
// Reddit's own RSS feeds — these are public, no auth needed.
|
||||
// We use www.reddit.com (old.reddit.com is deprecated).
|
||||
const REDDIT_RSS_BASE = 'https://www.reddit.com';
|
||||
|
|
|
|||
64
packages/feed-providers/src/providers/rss-bridge-feeds.ts
Normal file
64
packages/feed-providers/src/providers/rss-bridge-feeds.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
/**
|
||||
* RSS-Bridge Configuration — Crypto Content Sources
|
||||
*
|
||||
* Deployed at http://127.0.0.1:8282 on Talos.
|
||||
*
|
||||
* Each bridge URL can be added to the RSS provider's feed list.
|
||||
* Format: http://127.0.0.1:8282/?action=display&bridge={BridgeName}&format=Atom&{params}
|
||||
*
|
||||
* === Working Bridges (tested) ===
|
||||
*
|
||||
* FilterBridge — filter any RSS feed by keyword:
|
||||
* ?action=display&bridge=FilterBridge&format=Atom&url={encoded_rss_url}&filter={keyword}
|
||||
*
|
||||
* RedditBridge — subreddit feeds (alternative to Redlib):
|
||||
* ?action=display&bridge=RedditBridge&format=Atom&r={subreddit}&sort=hot
|
||||
*
|
||||
* CssSelectorBridge — scrape any website with CSS selectors:
|
||||
* ?action=display&bridge=CssSelectorBridge&format=Atom&url={encoded_url}&selector={css}
|
||||
*
|
||||
* === Bridges to configure ===
|
||||
*
|
||||
* Farcaster — add Farcaster feed:
|
||||
* ?action=display&bridge=FarcasterBridge&format=Atom&channel={channel_url}
|
||||
*
|
||||
* HackerNews — top stories:
|
||||
* ?action=display&bridge=HackerNewsBridge&format=Atom
|
||||
*
|
||||
* YouTube — channel videos (alternative to native RSS):
|
||||
* ?action=display&bridge=YoutubeBridge&format=Atom&c={channel_id}
|
||||
*
|
||||
* Twitch — streamer clips:
|
||||
* ?action=display&bridge=TwitchBridge&format=Atom&channel={channel_name}
|
||||
*
|
||||
* === Curated Bridge Feed URLs (add to DEFAULT_CRYPTO_FEEDS in rss.ts) ===
|
||||
*/
|
||||
|
||||
export const RSS_BRIDGE_FEEDS = [
|
||||
// HackerNews top stories (tech/startup news)
|
||||
"http://127.0.0.1:8282/?action=display&bridge=HackerNewsBridge&format=Atom",
|
||||
|
||||
// GitHub trending repos (daily)
|
||||
"http://127.0.0.1:8282/?action=display&bridge=GithubTrendingBridge&format=Atom",
|
||||
|
||||
// Lobsters (programming community, high signal)
|
||||
"http://127.0.0.1:8282/?action=display&bridge=LobstersBridge&format=Atom",
|
||||
|
||||
// Google News — crypto topic
|
||||
"http://127.0.0.1:8282/?action=display&bridge=GoogleNewsBridge&format=Atom&q=cryptocurrency&hl=en",
|
||||
|
||||
// YouTube: Bankless
|
||||
"http://127.0.0.1:8282/?action=display&bridge=YoutubeBridge&format=Atom&c=UCAl9Ld79qaZxp9JzEOwd3aA",
|
||||
|
||||
// YouTube: Coin Bureau
|
||||
"http://127.0.0.1:8282/?action=display&bridge=YoutubeBridge&format=Atom&c=UCqK_GSMbpiV8spgD3ZGloSw",
|
||||
|
||||
// Filter: Cointelegraph → bitcoin only
|
||||
"http://127.0.0.1:8282/?action=display&bridge=FilterBridge&format=Atom&url=https%3A%2F%2Fcointelegraph.com%2Frss&filter=bitcoin",
|
||||
|
||||
// Filter: Decrypt → ethereum only
|
||||
"http://127.0.0.1:8282/?action=display&bridge=FilterBridge&format=Atom&url=https%3A%2F%2Fdecrypt.co%2Ffeed&filter=ethereum",
|
||||
|
||||
// Filter: Coindesk → regulation only
|
||||
"http://127.0.0.1:8282/?action=display&bridge=FilterBridge&format=Atom&url=https%3A%2F%2Fwww.coindesk.com%2Farc%2Foutboundfeeds%2Frss%2F&filter=regulation",
|
||||
];
|
||||
|
|
@ -622,7 +622,7 @@ export class RssProvider implements FeedProvider {
|
|||
private readonly feeds: string[];
|
||||
|
||||
constructor(options: RssProviderOptions = {}) {
|
||||
this.feeds = options.feeds ?? DEFAULT_CRYPTO_FEEDS;
|
||||
this.feeds = [...new Set(options.feeds ?? DEFAULT_CRYPTO_FEEDS)];
|
||||
}
|
||||
|
||||
async fetchFeed(opts: FetchOptions = {}): Promise<FeedResult> {
|
||||
|
|
|
|||
|
|
@ -19,7 +19,9 @@ import type { FeedProvider, FeedResult, FetchOptions, HealthResult } from '../ty
|
|||
import { DEFAULT_TIMEOUT_MS, fetchText, withTimeout } from '../utils';
|
||||
|
||||
export const THREADS_WEB_URL = 'https://www.threads.net';
|
||||
export const FLARESOLVERR_URL = 'http://100.104.130.92:8191/v1';
|
||||
export const FLARESOLVERR_URL = typeof process !== "undefined" && process.env?.FLARESOLVERR_URL
|
||||
? process.env.FLARESOLVERR_URL
|
||||
: ""; // Set FLARESOLVERR_URL env var or leave empty to disable
|
||||
|
||||
export const CURATED_THREADS_HANDLES = [
|
||||
'zuck',
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ export const PLATFORM_TIER: Record<Protocol, number> = {
|
|||
threads: 0.7,
|
||||
reddit: 0.8,
|
||||
rss: 0.8,
|
||||
ponder: 0.8,
|
||||
ethereum: 0.6,
|
||||
solana: 0.6,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ export type Protocol =
|
|||
| 'mastodon'
|
||||
| 'reddit'
|
||||
| 'ethereum'
|
||||
| 'ponder'
|
||||
| 'solana';
|
||||
|
||||
export interface UnifiedIdentity {
|
||||
|
|
@ -117,6 +118,8 @@ export interface UnifiedPost {
|
|||
/** Niche tags derived from content (crypto, ai, oss, security, art, music) */
|
||||
niches: string[];
|
||||
/** Optional ranking breakdown for transparency / hover signals. */
|
||||
/** Protocols this post was also found on (cross-post dedup). */
|
||||
crossPostedTo?: Protocol[];
|
||||
score?: PostScore;
|
||||
/** Optional paywall requirement for paid content */
|
||||
paywall?: { amount: string; recipient: string };
|
||||
|
|
|
|||
12
pnpm-lock.yaml
generated
12
pnpm-lock.yaml
generated
|
|
@ -213,6 +213,12 @@ importers:
|
|||
'@degenfeed/types':
|
||||
specifier: workspace:*
|
||||
version: link:../types
|
||||
'@noble/curves':
|
||||
specifier: ^1.6.0
|
||||
version: 1.9.7
|
||||
'@noble/hashes':
|
||||
specifier: ^1.5.0
|
||||
version: 1.8.0
|
||||
devDependencies:
|
||||
'@biomejs/biome':
|
||||
specifier: ^2.5.2
|
||||
|
|
@ -7951,7 +7957,7 @@ snapshots:
|
|||
|
||||
'@scure/bip32@1.7.0':
|
||||
dependencies:
|
||||
'@noble/curves': 1.9.1
|
||||
'@noble/curves': 1.9.7
|
||||
'@noble/hashes': 1.8.0
|
||||
'@scure/base': 1.2.6
|
||||
|
||||
|
|
@ -12799,8 +12805,8 @@ snapshots:
|
|||
ox@0.6.7(typescript@5.9.3)(zod@3.22.4):
|
||||
dependencies:
|
||||
'@adraffy/ens-normalize': 1.11.1
|
||||
'@noble/curves': 1.8.1
|
||||
'@noble/hashes': 1.7.1
|
||||
'@noble/curves': 1.9.7
|
||||
'@noble/hashes': 1.8.0
|
||||
'@scure/bip32': 1.6.2
|
||||
'@scure/bip39': 1.5.4
|
||||
abitype: 1.0.8(typescript@5.9.3)(zod@3.22.4)
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import { handleCron } from './routes/cron';
|
|||
import { handleNewsletterFeed, handleTopFeed } from './routes/curated';
|
||||
import { handleDiscover } from './routes/discover';
|
||||
import { handleEngage } from './routes/engage';
|
||||
import { handlePushSend, handlePushSubscribe } from "./routes/push";
|
||||
import {
|
||||
handleCategoryFeed,
|
||||
handleFeedHealth,
|
||||
|
|
@ -109,6 +110,8 @@ export interface Env {
|
|||
SENTRY_DSN?: string;
|
||||
/** Optional Postmark server token for transactional email delivery */
|
||||
POSTMARK_SERVER_TOKEN?: string;
|
||||
ALLOW_DEV_OAUTH?: string;
|
||||
ADMIN_API_KEY?: string;
|
||||
}
|
||||
|
||||
// ─── Shared constants ────────────────────────────────────────────────
|
||||
|
|
@ -246,7 +249,7 @@ const CSRF_EXEMPT_PATHS = [
|
|||
// /api/auth/gotosocial/callback is now protected by a signed state nonce
|
||||
// (see routes/auth.ts) AND a normal origin check, so it's no longer exempt.
|
||||
'/api/tip/frame/',
|
||||
'/api/bridge/rss-to-nostr',
|
||||
// '/api/bridge/rss-to-nostr' is NO LONGER exempt — requires CSRF check
|
||||
'/api/blink/tip',
|
||||
'/api/x402/verify',
|
||||
];
|
||||
|
|
@ -456,10 +459,22 @@ export default {
|
|||
response = await handleNotify(request, env);
|
||||
} else if (path === '/api/discover') {
|
||||
response = await handleDiscover(request, env);
|
||||
} else if (path === "/api/push/subscribe") {
|
||||
response = await handlePushSubscribe(request, env);
|
||||
} else if (path === "/api/push/send") {
|
||||
response = await handlePushSend(request, env);
|
||||
} else if (path === '/api/newsletter/subscribe') {
|
||||
response = await handleNewsletterSubscribe(request, env);
|
||||
} else if (path === "/api/push/subscribe") {
|
||||
response = await handlePushSubscribe(request, env);
|
||||
} else if (path === "/api/push/send") {
|
||||
response = await handlePushSend(request, env);
|
||||
} else if (path === '/api/newsletter/status') {
|
||||
response = await handleNewsletterStatus(request, env);
|
||||
} else if (path === "/api/push/subscribe") {
|
||||
response = await handlePushSubscribe(request, env);
|
||||
} else if (path === "/api/push/send") {
|
||||
response = await handlePushSend(request, env);
|
||||
} else if (path === '/api/newsletter/digest' || path.startsWith('/api/newsletter/digest')) {
|
||||
response = await handleNewsletterDigest(request, env);
|
||||
} else if (path === '/api/search/identity') {
|
||||
|
|
|
|||
|
|
@ -41,8 +41,16 @@ async function consumeState(env: Env, state: string): Promise<boolean> {
|
|||
*/
|
||||
export async function handleGtSAuthorize(request: Request, env: Env): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
const redirect = url.searchParams.get('redirect') || getGtsConfig(env).redirectUri;
|
||||
const devMode = url.searchParams.get('dev') === 'true';
|
||||
const rawRedirect = url.searchParams.get('redirect');
|
||||
const allowedRedirects = [
|
||||
getGtsConfig(env).redirectUri,
|
||||
'https://degenfeed.xyz/auth/callback',
|
||||
'https://app.degenfeed.xyz/auth/callback',
|
||||
];
|
||||
const redirect = rawRedirect && allowedRedirects.some((r) => rawRedirect.startsWith(r))
|
||||
? rawRedirect
|
||||
: getGtsConfig(env).redirectUri;
|
||||
const devMode = env.ALLOW_DEV_OAUTH === 'true' && url.searchParams.get('dev') === 'true';
|
||||
|
||||
const state = generateState();
|
||||
await storeState(env, state);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
isSpamPost,
|
||||
} from '@degenfeed/feed-core';
|
||||
import {
|
||||
PonderProvider,
|
||||
BlueskyProvider,
|
||||
FarcasterProvider,
|
||||
type FeedProvider,
|
||||
|
|
@ -38,6 +39,7 @@ export const DEFAULT_PROTOCOLS: Protocol[] = [
|
|||
'bluesky',
|
||||
'mastodon',
|
||||
'threads',
|
||||
'ponder',
|
||||
];
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 6000;
|
||||
|
|
@ -60,6 +62,8 @@ export function getProvider(protocol: Protocol, env: Env): FeedProvider {
|
|||
return new ThreadsProvider();
|
||||
case 'reddit':
|
||||
return new RedditProvider();
|
||||
case 'ponder':
|
||||
return new PonderProvider();
|
||||
case 'rss':
|
||||
return new RssWithFallbackProvider(env);
|
||||
default:
|
||||
|
|
|
|||
|
|
@ -452,12 +452,16 @@ export async function handleNewsletterDigest(request: Request, env: Env): Promis
|
|||
return noCacheJsonResponse(response);
|
||||
}
|
||||
|
||||
export async function handleNewsletterStatus(_request: Request, env: Env): Promise<Response> {
|
||||
export async function handleNewsletterStatus(request: Request, env: Env): Promise<Response> {
|
||||
const auth = request.headers.get('authorization') ?? '';
|
||||
if (auth !== `Bearer ${env.ADMIN_API_KEY ?? ''}`) {
|
||||
return jsonResponse({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
try {
|
||||
const idx = (await env.CACHE?.get('newsletter:subscribers')) ?? '';
|
||||
const emails = idx.split('\n').filter(Boolean);
|
||||
return jsonResponse({ count: emails.length, sample: emails.slice(0, 5) });
|
||||
return jsonResponse({ count: emails.length });
|
||||
} catch {
|
||||
return jsonResponse({ count: 0, sample: [], warning: 'KV unavailable' });
|
||||
return jsonResponse({ count: 0, warning: 'KV unavailable' });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,8 +17,16 @@ import {
|
|||
// ─── RSS-to-Nostr bridge ─────────────────────────────────────────────
|
||||
// Fetches an RSS feed, converts each item to a Nostr text event,
|
||||
// signs with the provided key, and publishes to Nostr relays.
|
||||
//
|
||||
// SECURITY: This endpoint accepts a raw nsec in the request body.
|
||||
// We validate the nsec format and apply strict rate limiting. In a
|
||||
// future iteration, we should replace this with a pre-signed event
|
||||
// model where the client signs locally and only submits the signed
|
||||
// event to the server. The raw nsec should never traverse the wire.
|
||||
|
||||
export async function handleRssToNostrBridge(request: Request, _env: Env): Promise<Response> {
|
||||
const MAX_BRIDGE_PER_HOUR = 3;
|
||||
|
||||
export async function handleRssToNostrBridge(request: Request, env: Env): Promise<Response> {
|
||||
try {
|
||||
const body = (await request.json()) as Record<string, string>;
|
||||
const feedUrl = body.feedUrl;
|
||||
|
|
@ -26,6 +34,9 @@ export async function handleRssToNostrBridge(request: Request, _env: Env): Promi
|
|||
if (!feedUrl || !nsec) {
|
||||
return jsonResponse({ error: 'feedUrl and nsec are required' }, 400);
|
||||
}
|
||||
if (!/^nsec1[023456789acdefghjklmnpqrstuvwxyz]{58}$/.test(nsec.trim())) {
|
||||
return jsonResponse({ error: 'Invalid nsec format' }, 400);
|
||||
}
|
||||
|
||||
// 1. Fetch and parse RSS
|
||||
const { GenericRssSource, feedToUnifiedPosts } = await import('@degenfeed/rss-sdk');
|
||||
|
|
|
|||
77
workers/api/src/routes/push.ts
Normal file
77
workers/api/src/routes/push.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
/**
|
||||
* PWA Push Notification Worker
|
||||
*
|
||||
* Routes:
|
||||
* POST /api/push/subscribe — store a push subscription in KV
|
||||
* POST /api/push/send — send a push to all subscribers (admin)
|
||||
*/
|
||||
import { jsonResponse, type Env } from "../index";
|
||||
|
||||
interface PushSubscription {
|
||||
endpoint: string;
|
||||
keys: { p256dh: string; auth: string };
|
||||
subscribedAt: number;
|
||||
}
|
||||
|
||||
export async function handlePushSubscribe(request: Request, env: Env): Promise<Response> {
|
||||
try {
|
||||
const body = await request.json() as PushSubscription;
|
||||
if (!body.endpoint || !body.keys?.p256dh || !body.keys?.auth) {
|
||||
return jsonResponse({ error: "Invalid subscription" }, 400);
|
||||
}
|
||||
const sub: PushSubscription = { ...body, subscribedAt: Date.now() };
|
||||
await env.CACHE?.put(`push:sub:${body.endpoint}`, JSON.stringify(sub), { expirationTtl: 30 * 86400 });
|
||||
return jsonResponse({ ok: true });
|
||||
} catch {
|
||||
return jsonResponse({ error: "Invalid request" }, 400);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handlePushSend(request: Request, env: Env): Promise<Response> {
|
||||
const auth = request.headers.get("authorization") ?? "";
|
||||
if (auth !== `Bearer ${env.ADMIN_API_KEY ?? ""}`) {
|
||||
return jsonResponse({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
try {
|
||||
const { title, body, icon, url } = await request.json() as {
|
||||
title: string; body: string; icon?: string; url?: string;
|
||||
};
|
||||
if (!title || !body) return jsonResponse({ error: "title and body required" }, 400);
|
||||
|
||||
const subs = await listKVKeys(env.CACHE, "push:sub:");
|
||||
const payload = JSON.stringify({ title, body, icon: icon ?? "/icon-192.png", data: { url } });
|
||||
const results: { endpoint: string; ok: boolean; error?: string }[] = [];
|
||||
|
||||
for (const key of subs) {
|
||||
const raw = await env.CACHE?.get(key);
|
||||
if (!raw) continue;
|
||||
const sub = JSON.parse(raw) as PushSubscription;
|
||||
try {
|
||||
const res = await fetch(sub.endpoint, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: payload,
|
||||
});
|
||||
results.push({ endpoint: sub.endpoint, ok: res.ok });
|
||||
} catch (e) {
|
||||
results.push({ endpoint: sub.endpoint, ok: false, error: String(e) });
|
||||
}
|
||||
}
|
||||
return jsonResponse({ sent: results.length, results });
|
||||
} catch (e) {
|
||||
return jsonResponse({ error: String(e) }, 500);
|
||||
}
|
||||
}
|
||||
|
||||
async function listKVKeys(kv: KVNamespace | undefined, prefix: string): Promise<string[]> {
|
||||
if (!kv) return [];
|
||||
const keys: string[] = [];
|
||||
let cursor: string | undefined;
|
||||
do {
|
||||
const result = await kv.list({ prefix, cursor });
|
||||
keys.push(...result.keys.map((k) => k.name));
|
||||
cursor = result.list_complete ? undefined : result.cursor;
|
||||
} while (cursor);
|
||||
return keys;
|
||||
}
|
||||
|
|
@ -18,10 +18,21 @@ function jsonResponse(data: unknown, status = 200): Response {
|
|||
});
|
||||
}
|
||||
|
||||
function decodeHex(hex: string): Uint8Array {
|
||||
const bytes = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0; i < hex.length; i += 2) {
|
||||
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
async function importKey(secret: string): Promise<CryptoKey> {
|
||||
const keyBytes = /^[0-9a-fA-F]+$/.test(secret) && secret.length >= 64
|
||||
? decodeHex(secret)
|
||||
: new TextEncoder().encode(secret);
|
||||
return crypto.subtle.importKey(
|
||||
'raw',
|
||||
new TextEncoder().encode(secret),
|
||||
keyBytes as Uint8Array<ArrayBuffer>,
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign', 'verify'],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue